Compare commits

...
Author SHA1 Message Date
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
jaap-jan 8e61fc72b4 Stop an attach's own replay landing in the middle of a test's frames
ci / build and test (pull_request) Successful in 7m43s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Failing after 11s
ci / android head (pull_request) Successful in 8m7s
TheClipboard_ReachesThePaneThatAskedForIt failed in CI on an opcode of 2
where it wanted 8: a second SessionOpened frame where the clipboard's
should have been. Only one thing sends a session a second SessionOpened,
and that is ReplayAfterAttachAsync, the replay a reattaching page gets.

It arrives there because the replay is fired and forgotten from the
socket-accept thread immediately after the attach is signalled, and the
test helper returned on that signal. Deschedule the accept thread for a
timeslice between the two -- which a loaded CI box does -- and the test
opens its session first, so the replay finds that session live and sends
it a replay-flagged SessionOpened after its real one.

Nothing is wrong with the workspace. The page treats a replay onto a pane
it still has as idempotent, and ReplayAfterAttachAsync's own remarks
already say racing a fresh page's first frames is harmless. What was
wrong is a helper that waited for the attach and then let its callers
count frames as though the attach were finished, so ConnectRendererAsync
now waits for RendererReattached as well -- raised as the last thing the
replay does. The remark there argued a replay could not be sent early; it
missed that it can be sent late, and now says so.

The same late replay could reset the credit window out from under
ANewRenderer_ReplaysTheLiveSessionAndResetsItsCredits, which shares the
helper, so that latent flake goes with it.

Verified by delaying the replay's snapshot by a millisecond: the old test
fails under that, and the fixed class passes it eight runs running.
2026-09-15 14:29:08 +02:00
jaap-jan 8a4058ce3a Merge pull request 'Stop a reconnect dialling a different machine of the same name' (#17) from claude/reconnect-wrong-server-9cccfb into main
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m24s
ci / build and test (push) Failing after 2m20s
Reviewed-on: #17
2026-09-14 11:31:04 +00:00
jaap-jan 2d95366552 Stop a reconnect dialling a different machine of the same name
ci / build and test (pull_request) Successful in 3m31s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 7m42s
ci / api image (pull_request) Failing after 13s
A tab remembered what its host was called and nothing else, and the reconnect
under a stopped tab looked the host up by that name: HostFor took the first row
in the keychain whose label matched. Nothing in this application stops two hosts
being called the same thing — a team's vault beside a personal one makes it
ordinary, and "prod-db" is what the database server gets called in both — so the
button dialled whichever namesake sat earliest in the list. A dropped session on
one machine came back as a live shell on another, with the tab, the address and
the status bar all still naming the first. There was nothing on screen to read
the substitution off.

The name match was not an oversight; it was an argument, written into the remark
on HostFor and repeated on RefreshConnectedHosts: a tab outlives the vault that
opened it, so it "cannot hold an entity id that would still mean anything after a
lock". That is not so. EntityId is the item's own sync identity rather than a
reference to a row object, so a vault that is locked and opened again rebuilds
every row around the same ids. The one handle that was rejected as too fragile to
survive a lock is the handle that survives it.

So the id now travels with the connection. ConnectionAttemptEventArgs and
TerminalSessionEventArgs carry it, taken from ConnectionTarget.HostId, which the
connect path has had all along for the log; TerminalTabViewModel keeps it; and
HostFor matches on it. A host renamed since the tab opened is found too, which a
name match by definition cannot. Null is not missing data — it is the whole of
what makes a typed-in address different, and it is why such a tab is refused a
reconnect rather than offered one that would dial a stranger sharing its name.

Three other lookups had grown their own copy of the same match and so had the
same fault, where it is read rather than dialled: the green connected dot, which
lit on every machine answering to the name and claimed a shell on hosts nobody
had connected to; the pinned paths QUICK ACCESS offers, which come from whichever
row that same match found; and the sidebar's "Pin folder" and the SFTP tab row,
which would have edited and browsed the wrong host. All four now go through
HostFor, so "which machine is this tab" has one answer.

Both new tests assert on the address actually dialled rather than on anything the
tab says about itself. The two rows agree on every string a tab carries, which is
exactly why this went unnoticed: a test written against the label would have
passed on the day the wrong machine was being opened.
2026-09-14 10:18:58 +02:00
jaap-jan 9ead42171c Stop a flooding remote growing the process, and let an ended session go
ci / build and test (push) Successful in 3m12s
ci / desktop nightly (push) Successful in 1m16s
ci / api image (push) Successful in 45s
ci / android head (push) Successful in 7m11s
The terminal's memory was bounded by an argument that is not true of the SSH
library underneath it, and the whole of CreditWindow's remark was that argument:
the pump stops reading when the renderer falls behind, SSH's own receive window
closes, the remote sshd blocks on write, and the backpressure reaches the process
producing the output. The middle step does not happen. Renci.SshNet's
Channels.Channel.OnData calls its window adjustment before it raises DataReceived
and re-opens the channel window as soon as it drops below one packet, and
ShellStream's own handler copies the bytes into a System.Net.ArrayBuffer without
blocking and without a ceiling — a buffer that doubles on demand and is not given
back until the stream is disposed. So a reader that stopped reading slowed the
remote down by nothing at all. It only chose whose buffer the flood landed in,
and it chose the one nothing bounds.

Measured against the container this repository already runs sshd in: a reader
that reads nothing while `yes` runs leaves ShellStream holding 8.9 MB after one
second, 26.8 MB after three and 44.3 MB after five, its capacity stepping 16 → 32
→ 64 MB. That is the report of "a massive memory leak on Windows"; it is not a
Windows fault, it is simply where long sessions with heavy output live.

The fix is to read continuously, which is what keeps SSH.NET's buffer empty, and
to put the bound where it can actually be enforced. OutputBacklog is that bound:
a byte-capped queue of unsent output, 4 MiB by default, which drops its oldest
bytes when full and counts what it discarded. Oldest rather than newest, because
the tail of a flood is the prompt somebody is waiting for.

The two bounds are now separate and each is enforced where it means something.
The backlog bounds what is waiting to be sent. The credit window goes on bounding
what has been sent and not yet drawn — the queue inside the WebView that nothing
on this side can measure — and is reserved against in the flush loop rather than
the read loop. Reading is part of neither, because reading was never what the
remote was waiting on.

Dropping is announced rather than silent. A terminal quietly showing a different
transcript from the one the remote sent is worse than one that says it could not
keep up, because the second can be believed. The line goes out under its own
opcode, TerminalServerOpcode.OutputDropped, rather than spliced into the output
stream: what this application says about the transcript is not part of the
transcript, and keeping them apart is also what makes the arithmetic hold — every
byte read is either in an Output payload or counted in BytesDropped. It opens
with an SGR reset, because the cut lands at an arbitrary offset and whatever
escape sequence the remote was in the middle of has been left half-said.

Frames are now capped at 64 KiB and taken straight into the frame buffer. The cap
is the large-object-heap threshold rather than a guess — a quarter-megabyte array
allocated sixty times a second is collected only by a generation 2 pass — and the
direct take removes an allocation and a copy of every byte the terminal shows.

The second half is the one that made the first half's cost persist. Nothing gave
a session back when the remote hung up: TerminalWorkspace kept the entry, and
with it an undisposed ISshConnection — an authenticated socket, its cipher state,
and a keep-alive putting a packet on the wire every thirty seconds — plus the
pump and SSH.NET's read buffer at whatever size the session's busiest moment had
grown it to, for as long as the tab stayed open. A tab somebody typed `exit` into
an hour ago was indistinguishable from a live one in everything except what it
drew. ReleaseAsync disposes both, from AnnounceEndedAsync and after the
announcement: the tab is told first, because tearing down an SSH connection in
front of that would be latency on the one thing the user is watching for. The
dictionary entry stays, because LiveSessionCount, IsSessionLive and the tab's own
ENDED and RECONNECT all read it. SendInputAsync therefore asks about liveness
rather than presence, since the shell channel is gone the moment the remote is.

Two faults introduced on the way here and fixed, both worth naming because
neither would have been visible from the outside:

The read loop left through a condition on its cancellation token, and RunAsync
reads a read loop that returns as the remote having closed the channel. A tab the
user closed would have reported itself as a shell that exited on its own. It
throws instead.

With the reader finished and a renderer that had stopped acknowledging, the flush
loop span: output to send, no credit to send it with, and a wait for more output
that returned at once because there was some. RunAsync never completed, so the tab
was never told and the connection was never released — a session invisible and
permanent. The loop now leaves for the drain as soon as the backlog is complete,
and RunAsync resets the credit window alongside completing it so a flusher already
parked on an acknowledgement that will never come is released.

Four tests asserted the old contract and now assert its reverse, which is the
point rather than a relaxation — WhenCreditRunsOut_ThePumpStopsReading is now
WhenCreditRunsOut_ThePumpKeepsReadingAndTheBacklogHoldsTheLine, and has to prove
both halves at once. The throughput harness gained a counting transport, because
a consumer that copies every frame into a 32 MB list was the slowest thing in the
pipeline and a harness whose consumer cannot keep up measures itself. The fake
shell gained a pace, so "a renderer that keeps up loses nothing" can be asked of a
producer a network could actually deliver rather than of one running at memory
bandwidth.

The test that would have caught this in the first place is
ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog, and it needs the real
SSH.NET and the real sshd to say anything: a fake shell hands over bytes when it
is asked, and a real channel hands them over when they arrive. Under the old code
its BytesRead would have stopped at exactly WindowBytes.

121 terminal tests pass over three consecutive runs with no flakes, 105 against
the container, and the app, session, layout and remaining suites are unchanged and
green. Manual checks 1.10a and 1.10b cover the two symptoms a person can see: a
working set that plateaus under `yes`, and a connection that goes when the shell
does rather than when the tab does.
2026-09-04 17:16:23 +02:00
laurensandClaude Opus 5 597e5ae016 Take the release credentials off the command line, where anyone could read them
ci / build and test (push) Successful in 3m17s
ci / desktop nightly (push) Successful in 1m19s
ci / android head (push) Successful in 3m45s
ci / api image (push) Successful in 24s
Three scripts prompted for a secret with Read-Host -AsSecureString or read -s,
said in a comment that it therefore never lands in shell history, and then wrote
it straight into the arguments of the next process. The comment was true and was
answering the wrong question. A command line is not private: on Windows one call
to Get-CimInstance Win32_Process hands every argument of every process running as
this user to anything that asks, /proc/<pid>/cmdline is world-readable on Linux,
and process-creation auditing and EDR agents keep their own copy either way. The
values were the Android release signing key's passwords and the forge token —
between them the whole capability to ship an update that every installed copy
will fetch and run, which ADR 0011 rule 1 and ADR 0013 go to some length to keep
off a runner and which was then left in a process listing on a desk.

Both replacements are documented features of the tools rather than tricks.

The Android passwords use .NET for Android's env: prefix, which puts the name of
a variable on the command line and lets MSBuild read the value out of the
environment itself. Set immediately before the build and cleared in a finally, so
a failed signing does not leave them in the shell for whatever runs next, and
-nodeReuse:false so no persistent MSBuild worker outlives the call still holding
the inherited copy in its own environment.

That prefix is unsupported when AndroidPackageFormat is aab, and the failure is
silent — the package would be signed with the literal string
"env:DODOSSH_ANDROID_STOREPASS" as its password. So the format is read back and
the build stops if it is ever changed, in the same shape as the versionCode guard
a few lines above: a wrong release refused here rather than discovered later on
somebody's phone.

vpk documents VPK_TOKEN as --token's environment fallback, so the flag is simply
gone from both desktop scripts and the token never becomes an argument at all.
Unset afterwards on the failure path too, which on macOS needs the exit status
captured rather than a bare || stop_with.

release-macos.sh had the same fault and was missed when the other two were first
reported. Its Apple credentials were already right — a notarytool keychain profile
rather than --password — and only the forge token was exposed.

What this does not claim: an environment block is readable by a process holding
rights over this one, the same as its memory is. The comments say so rather than
implying the values are now safe. What is bought is real and narrower — off the
command line, out of every process listing, out of the audit trail, and out of any
log that echoes the invocation.

Both PowerShell files parse, all three shell scripts pass bash -n, the
AndroidPackageFormat probe evaluates and answers empty so the guard passes, and
dotnet build accepts -nodeReuse:false. The signing itself is not exercised here:
it needs the release keystore, which by design is not on this machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:31:02 +02:00
laurensandClaude Opus 5 483ae1fed9 Stop three notes in this repository claiming things that are not so
ci / build and test (push) Successful in 3m3s
ci / android head (push) Successful in 4m2s
ci / desktop nightly (push) Successful in 1m30s
ci / api image (push) Successful in 43s
A scan of the working tree and all 335 commits for anything that should not be
public found no leaked secret. What it did find was three places where a reader
is told something the repository does not back up, and in a public repository a
confident wrong note is worse than silence: nobody goes and checks a sentence
that sounds settled.

The ignore list stopped at *.pfx, *.p12 and *.snk, so a keystore was not covered
at all. The one that matters is the release keystore ADR 0011 rule 1 keeps off
runners — drop it in the tree while cutting a release and nothing would have said
a word. *.keystore and *.jks now, with build/nightly.keystore negated: an ignore
rule cannot untrack a tracked file, so the negation changes no behaviour and is
there so the next reader does not take the committed one for an accident. ADR
0014 is cited beside it, because "a signing key is committed here on purpose" is
a claim that needs its argument within reach.

Also gone from that list: a negation for deploy/.env.example, which has never
existed in any commit.

RelayOptions.AllowPrivateNetworks said loopback, link-local and cloud metadata
ranges "are denied unconditionally and are not configurable" — present tense, in
the voice of a control that exists. Nothing reads the setting. There is no relay
endpoint and no dialler; the relay is ADR 0004 and a contract type. So the
sentence most likely to be read by somebody auditing this repository's SSRF
posture was describing code nobody has written. It now says that outright, and
hands the obligation to whoever builds the dialler with ADR 0004 §3 named in
full, including the IPv4-mapped IPv6 re-check, which is the clause a fresh
implementation is likeliest to miss. The flag governs RFC1918 and never that
list, which was the other half the old wording ran together.

docker-compose.dev.yml pointed at deploy/docker-compose.yml as "the production
stack". That file has never existed here either. Says what does ship — the image
the CI image job builds and pushes — and puts a compose file to run it where it
belongs, in M4's self-hosting guide.

Comments and an ignore list only; no behaviour moves. The API builds clean, and
the two tests that mention the dev compose file read its Postgres version, which
is untouched.

Left alone deliberately: release-android.ps1 and release-windows.ps1 pass the
keystore password and the forge token as arguments to a child process, where
another process on the machine can read them off the command line. That is a real
if small hole and a code change rather than a comment — the vpk half needs
somebody to find out whether it takes the token from the environment first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:35:14 +02:00
laurensandClaude Opus 5 7ee2aacdc9 Put check 1.12 back in step with the splash it describes
ci / build and test (push) Successful in 2m57s
ci / android head (push) Successful in 3m38s
ci / api image (push) Successful in 22s
ci / desktop nightly (push) Successful in 1m9s
The progress bar went in "Take the frozen bar off the splash, and leave the line
that is true" and the manual check still told a person to watch it move, and then
to watch it stop. A check that names something not on the screen is worse than no
check: whoever runs it has to decide whether they are looking at a regression or
at a stale document, and the honest answer is not available from the page.

Says what the failure state looks like now — the line changes and nothing else on
the card is there to contradict it — and records where the bar went, so the next
reader is not left wondering whether it was lost by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:26:52 +02:00
laurensandClaude Opus 5 dec2cf164a Let the connecting card's host name and address be taken away
The card that fills the terminal's rectangle while a connection is being made
prints two lines saying which machine and at what address, and both were
TextBlocks — which in Avalonia is a drawing of a string rather than a string.

It is the one place the shell says what it is dialling *before* anything has
answered, and on a refusal it is the only copy of that on screen: there is no
prompt yet and there may never be one. That is exactly when the value is wanted
somewhere else — in a ticket, in a message to whoever owns the host, in the ssh
command being written in the window beside this one — and retyping it off the
screen is how a digit goes missing from an address that is mostly digits.

SelectableTextBlock, the same change the session column's own address got in
"Let the session address be selected, and copied whole" and for the same reason.
Nothing else about either line moves: same font, size, weight, colour, trimming
and centring. The refusal underneath them was already selectable, which made the
pair above it the odd ones out.

Both lines rather than only the name. They are two lines of one identifier, and a
pair where the first can be taken and the second cannot is a worse answer than
either.

Tooltips are new, and they are what makes the trimming safe rather than a trap.
A drag reaches only the glyphs that were drawn, so a name or an address wearing
an ellipsis has to be taken with select-all — which takes the whole value,
because the ellipsis is a drawing and not a shorter string. The hover says what
the whole value is, so nobody copies half of one without having been told there
is a half. Same argument, same shape, as the sidebar's.

The test asserts that half rather than the easy one, on values long enough to be
trimmed inside the card's 460 pixels; values that fit would pass whether the
trimming were render-only or not. Confirmed it bites by putting the two
TextBlocks back and watching it fail.

The phone draws the same two lines in the same state — TerminalScreen.axaml's
IsConnectingShowing block — and is deliberately left alone. Its captions are
styled by TextBlock.title and TextBlock.detail selectors in Phone.axaml, so the
same substitution there silently drops the styling until those rules gain
SelectableTextBlock siblings, which is a theme change rather than this one. Worth
doing; not worth smuggling in here.

Also: WindowDecorations on the splash instead of SystemDecorations, which
Avalonia 12 has deprecated in favour of it. That warning was mine, added two
commits ago; the main window's own is older and is not this change's to move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:25:43 +02:00
laurensandClaude Opus 5 7d1f0a4b53 Take the frozen bar off the splash, and leave the line that is true
ci / build and test (push) Successful in 3m3s
ci / android head (push) Successful in 3m56s
ci / api image (push) Successful in 21s
ci / desktop nightly (push) Successful in 2m12s
The indeterminate bar did not move, and it was never going to. Composition is
a synchronous block and DodoSshApp posts it to the very dispatcher that would
have to animate it: Background priority buys the splash its first paint and
nothing after it, because once composition starts the UI thread is inside it
until the main window is ready, and a ProgressBar's indeterminate animation is
driven by that thread's clock. One frame, then ~2.4 seconds of stillness — the
whole of the time the window is on screen.

A frozen indeterminate bar does not read as indeterminate. It reads as a
determinate bar stuck at zero, which is worse than no bar at all: it says the
work has stalled at the moment the application is busiest, which is the one
thing a splash must never say.

Freeing the UI thread would have been the other fix, and it is a real one —
the I/O in Compose has no business on that thread. It is also a rework of
startup for the sake of an ornament. There is nothing here that can honestly
be measured anyway: a SQLite open, a loopback bind, a TPM probe and an
install-layout read report no progress between them. So the bar goes.

"Starting…" was already on the card, it is true for as long as the card is up,
it needs no clock to stay true, and the window closing is what says it
finished. CouldNotStart is one assignment now — it used to have to stop the
bar as well, so that nothing went on claiming to work over a process that had
died, and there is no longer anything on the card that could.

The window keeps its MinHeight, which is what stops two lines of content
collapsing it, and the failure path keeps SizeToContent for the same reason it
always had it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:18:34 +02:00
laurensandClaude Opus 5 fd92b80d4f Find out why a connection did not reach, instead of only saying it timed out
ci / android head (push) Successful in 4m15s
ci / api image (push) Successful in 26s
ci / build and test (push) Successful in 3m3s
ci / desktop nightly (push) Successful in 2m15s
Measured against SSH.NET 2026.0.0 before anything was written, because the
premise needed checking. Four ways of failing to reach a host, and what this
client puts in front of the user for each:

  server accepts TCP, never sends a banner  ->  "Connection has timed out."
  packets black-holed, nothing comes back   ->  "Connection has timed out."
  nothing listening on the port             ->  "...actively refused it."
  name does not resolve                     ->  "No such host is known."

The first two are the same five words and are not the same problem. One is a
network, a firewall or a wrong address; the other is a machine that is up and
well and an sshd that is wedged, or a port that is not SSH at all. Nothing in
the exception tells them apart, and that sentence was the whole of what this
product could say about the failure people hit most.

◆ A SOCKET ANSWERS IT, IN ABOUT AS LONG AS IT TAKES TO OPEN ONE.

SshReachabilityProbe resolves the name and opens a bare TCP connection, and the
four answers it can come back with are the four somebody would do something
different about — unresolved, refused, silent, open. Every one of them is what
a socket actually did, measured at the moment it was asked, rather than
anything inferred from the exception that started it.

Deliberately nothing else. No ping: ICMP is filtered on most of the networks
this is pointed at, so an unanswered one would be evidence of nothing. No scan
of neighbouring ports — the question is what happened to the connection the
user asked for, and probing ports nobody asked about is how a client gets
flagged by an intrusion detector on the very network somebody is trying to
work on. No second SSH handshake against a server that has already failed one.

The budget is six seconds and the floor under it is not a guess: a refusal is
not immediate on Windows, and a closed loopback port took ~2s to answer when
this was measured, because the stack retries the SYN first. At two seconds the
probe calls a machine that is up and refusing a firewall. The test for that
case runs at the real budget on purpose, so lowering the number fails it.

◆ ONLY WHILE REACHING, AND THE WINDOW IS CLOSED AT BOTH ENDS.

Past SshConnectionPhase.Reaching the machine has offered a host key, so it has
demonstrably been reached and a refused password is not a reachability
question. Before it, nothing has been dialled at all — a private key that will
not parse throws out of BuildConnectionInfo before the factory reports
anything — and probing there finds a healthy port and appends "the SSH
handshake is what did not finish" to a message about an unreadable key. That
is a confident wrong answer replacing an accurate one, so both ends are shut.
PhaseReporter keeps the furthest phase to decide it.

The finding is added to what SSH.NET said rather than replacing it: the
exception is the fact, and this is a reading of a second measurement.

Every shell in ShellFlowTests now gets a scripted probe. Without one a failed
connection in any test in that file would do a live DNS lookup for db.internal
and open a real socket — slow, network-dependent, and worse than either, since
db.internal resolves on any machine whose resolver has a search domain that
makes it, which would make the asserted sentence depend on whose network the
suite ran from.

◆ WHAT THIS DOES NOT FIX, FOUND WHILE READING THE SAME PATH.

  - SshConnectionRequest.ConnectTimeout is documented, plumbed, and passed by
    nothing. Every connection this client makes is hard-wired to the 15-second
    default, and there is no per-host field and no setting that can change it.
    Fifteen seconds bounds the handshake waits as well as the connect — a
    server that accepts TCP and never sends a banner fails at exactly the
    timeout, measured — where OpenSSH's own ConnectTimeout covers only the
    connect. If timeouts are frequent on slow links, this is the first
    suspect, and it needs a product decision rather than a patch.

  - The known-host lookup in HostKeyGate.OnHostKeyReceived blocks SSH.NET's
    message thread inside that same 15-second budget. The blocking is
    acknowledged in the code; that it competes with the key exchange's own
    timeout is not. On a cold or locked vault a slow read would surface as a
    connection timeout, which is exactly the symptom above.

  - ConnectThroughHostKeyGateAsync never unsubscribes HostKeyReceived on
    success, so the gate outlives the handshake holding the connect-time
    CancellationToken. If SSH.NET raises that event again on rekey it would
    re-enter the blocking store read with a token whose source is long gone.
    Not verified either way — it needs a real sshd and a forced rekey.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:05:12 +02:00
laurensandClaude Opus 5 dabc691c35 Keep the doorbell listening between callers, and stop a hung test taking CI with it
ci / build and test (push) Successful in 2m58s
ci / api image (push) Successful in 22s
ci / desktop nightly (push) Successful in 1m47s
ci / android head (push) Successful in 3m56s
The test step ran for twenty minutes and had to be stopped by hand. Eighteen suites
had passed; DodoSSH.Client.App.Tests never reported.

◆ ON UNIX, A FRESH SERVER PER CONNECTION LOSES THE NEXT ONE.

The doorbell's accept loop built a NamedPipeServerStream inside the loop, served one
caller and disposed it. That is fine on Windows and quietly wrong on Linux, where a
named pipe is a Unix domain socket: disposing the server closes the *listening*
socket and unlinks its path, so a second launch that connected while the first was
being served has its connection dropped. Its Connect returned, its write succeeded
into a buffer, and nobody ever read it — the launch was told it had rung the
doorbell and the window never came forward.

Reproduced in a container rather than reasoned about, because the two platforms
disagree and this machine is only one of them. Three rings back to back:

    one instance, Disconnect between callers   ->  3 of 3 answered
    a fresh instance per connection            ->  1 of 3 answered

So there is one server for the life of the doorbell now, Disconnect()ed between
conversations, and the socket is never not listening. Which makes leaveOpen on the
reader load-bearing too: a StreamReader disposes the stream it was given, and that
stream now has to survive into the next iteration.

Two things I had wrong about this, both worth recording. Cancelling
WaitForConnectionAsync *does* unblock a pending accept on Linux — that was my first
theory and the container disproved it, so DisposeAsync awaiting the loop was never
the problem. And the app itself never hung: the lost ring is a launch that is
ignored, not a process that stops.

◆ AND THE REASON IT COST TWENTY MINUTES RATHER THAN TWENTY SECONDS.

    await twice.Task.WaitAsync(TestContext.Current.CancellationToken);

That token carries no timeout. A ring that never arrives is then not a failing test,
it is a job that sits there — which is exactly what happened, and it is the more
expensive of the two mistakes because it turns any future bug of this shape into a
runner somebody has to go and stop. Both awaits are bounded now.

TheDoorbellAnswersMoreThanOnce keeps its shape deliberately: two rings back to back
rather than one and a pause, because the gap between accepts is the whole defect. A
test that waited for the first to be answered would pass against the broken version.

ci.yml had no timeout-minutes anywhere, on any job. Every job has a ceiling now, and
the test step has a tighter one of its own — ten minutes against a suite that takes
two — because a hang there is the likeliest of the lot and a step-level timeout names
the step instead of leaving a reader to work out which of fifteen was still running.
None of them is a performance budget; a build that legitimately grows past one should
have the number raised.

Verified both ways on Linux, in a container, against the committed tree and this one:

    31c836f  SingleInstanceTests  ->  hung in TheDoorbellAnswersMoreThanOnce,
                                      still going when killed at 3 minutes
    this     SingleInstanceTests  ->  7 of 7 in 1.3s
    this     the whole suite      ->  570 tests, 0 failed, 88s

And on Windows, where none of this was ever broken: 570 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:42:41 +02:00
laurensandClaude Opus 5 31c836f6ca Stop the RPM verification failing on packages that are correct
ci / android head (push) Successful in 3m46s
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 26m51s
CI run 161 failed the linux packaging step with

    The package was built and /opt/DodoSSH/DodoSSH is not in it.

and then listed the package contents, which contain /opt/DodoSSH/DodoSSH. The
package was fine. The check was not.

◆ A READER THAT STOPS EARLY, UNDER pipefail.

    if ! printf '%s\n' "$files" | grep -qxF "$expected"; then

grep -q exits on its first match. printf is still writing thousands of paths, so
it dies of SIGPIPE with status 141; pipefail makes 141 the pipeline's status; `!`
inverts it; and a check that found what it was looking for reports the opposite.

The exit code names the second instance of the same mistake. The diagnostic on the
way out is

    printf '%s\n' "$files" | head -40 >&2
    exit 1

and head stops at forty lines, so printf takes SIGPIPE again and `set -e` ends the
script there. Which is why the job exited 141 rather than the 1 the script asked
for, and why the listing in the log stops mid-alphabet.

◆ WHY IT LOOKED LIKE SOMEBODY ELSE'S COMMIT.

It is a threshold, not a constant. The writer only blocks once it has more than a
pipe buffer left to write — about 64 KB on Linux — so for as long as the published
tree was small the whole list fitted, printf finished, and the pipeline returned
grep's own 0. The check has been wrong since it was written and correct in effect
until the path text crossed 64 KB, which is what the commit before this one did.
Reproduced both sides of the threshold locally:

    4,440 bytes  -> pipeline status 0
    90,940 bytes -> pipeline status 141

Every reader in that block now consumes all of its input: `grep pattern
>/dev/null` in place of `grep -q pattern`, same exit status, reads to the end; and
`awk 'NR<=40'` in place of `head -40`, same forty lines, reads to the end. `|| true`
would have silenced the symptom and taken a real failure of the writer with it.

The other three checks in the same block had the same shape with small inputs —
requires and provides are dozens of lines — so they were latent rather than
failing. Fixed anyway, because the next thing to grow is not predictable from here.

Same for the three publish-verification loops in ci.yml, which grep a release's
JSON for each asset name it should carry. Small today; the loops exist because a
run once created a release, uploaded nothing and reported success, so a false
answer from them is the one thing they must not give.

`ensure skia's native dependency` is left alone deliberately. `ldconfig -p | grep
-q` is the same shape over an input that genuinely exceeds a pipe buffer, but that
step sets `-eu` without `pipefail`, so the pipeline's status is grep's and the
SIGPIPE is invisible. Changing it would be churn; the reason it is safe is worth
knowing.

Verified by lifting the patched loop out of the script and running it against a
synthetic 89 KB listing: complete package passes, a package genuinely missing
/opt/DodoSSH/DodoSSH exits 1 with its forty lines of diagnostic intact. The real
proof is the next run of the job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:02:58 +02:00
laurensandClaude Opus 5 b8c94ecb73 Put a window on screen while it starts, and refuse the second one
ci / build and test (push) Failing after 3m1s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 4m4s
Two things a desktop application is expected to do and this one did not.

◆ THREE SECONDS OF NOTHING.

Measured on a debug build against a cold profile: the main window arrives 3.1
seconds after the executable does. Nothing was on screen for any of it — the icon
had been double-clicked and the desktop looked unchanged.

The main window cannot come sooner. It needs a view model, and the view model needs
composition to have opened the SQLite cache, bound the terminal's loopback socket,
asked the platform about a TPM and read the install layout off disk. Its own
IsStarting card already narrates what happens after it appears; this is the part
before, which no arrangement of that window can cover.

So SplashWindow, and it is measurably the point: 668 ms to the splash, 3.1 s to the
window that replaces it.

Two mechanics are load-bearing rather than incidental.

The splash *is* desktop.MainWindow for a moment. The lifetime shows whatever that
property holds once OnFrameworkInitializationCompleted returns; a window merely
Show()n there would have no lifetime attached, and under
ShutdownMode.OnLastWindowClose the first close of anything would end the process.
Handing the property over is one assignment and Avalonia reads it lazily.

And composition is posted at DispatcherPriority.Background rather than called. It
is synchronous and slow — which is the entire argument for a splash — so running it
inline would block the very dispatcher turn that has to paint the thing.

The window is SizeToContent="Height", which is about the failure and not the
splash: CouldNotStart carries an exception's own message, and inside a fixed height
a long one would wrap and be clipped at exactly the moment that text is all the
window is for. A composition that throws is caught rather than left to the
dispatcher, for the same reason — before this there was nothing on screen to
abandon, and now there is, so an escaping exception would leave a window saying
"Starting…" over a process that had stopped. Escape closes it, and only then.

◆ AND ONE INSTANCE.

Claimed in Main before Avalonia is configured, so a second launch starts nothing at
all: no toolkit, no window, no splash flashing up before it thinks better of it.

Exclusion is an exclusive handle on instance.lock inside the profile directory, and
that location settles two questions at once. A nightly and a release build do not
exclude each other — separate profiles, ADR 0013 — and two people signed in to one
machine each get their own instance. A global mutex would have had to re-derive
both from somewhere.

A file handle rather than a named mutex because the kernel releases it however the
process ends. There is no such thing as a stale lock here, which is a state a
named mutex makes both platforms model differently and neither model simply.

The second launch is not silent. Somebody who double-clicks an icon and sees
nothing has been told the application is broken, so it rings a named pipe — named
from a digest of the profile path, so it is scoped exactly as the lock is — and the
running instance un-minimises and activates. Activate is a request and not a
guarantee: Windows refuses the foreground to a process that did not receive the
input, and the flashing taskbar button that results is still an answer.

Three seconds of retrying when the lock is held by something that does not answer
its pipe, and that window has one job. Velopack's apply ends this process and its
updater starts the new build; the two are ordered, but a lock held a moment too
long would make the successor refuse itself and vanish — an update that looked like
an uninstall. The doorbell is disposed before Shutdown for the same reason, so the
pipe name is free before the process is.

◆ VERIFIED BY RUNNING IT, WHICH IS THE ONLY WAY EITHER OF THESE COULD BE.

Two launches of the built executable: the first stayed up with instance.lock in the
profile, the second exited. Window sizes over time are where the two timings above
come from — 420x160 then 1196x776.

SingleInstanceTests covers what a single process can reach: the lock, its release
by disposal, the doorbell answering more than once, and two profiles not excluding
each other. SplashCardTests measures the one thing the splash can get wrong, which
is a failure message long enough to need the height the window is willing to give
it. Checks 1.12 and 16.11 are what needs a second executable and a real screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:49:15 +02:00
jaap-jan aff776cb42 Rebuild the terminal's keypad, and fix what a device run showed
ci / build and test (push) Failing after 3m0s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m56s
Six defects, all found by running this head on an emulator against a local API rather than by reading it.
They are in one commit because two of the files carry several of them and splitting those would mean
hand-editing patches; each is argued at its own site.

── the keypad, which is most of this ───────────────────────────────────────────────────────────────────
It was eleven 30-by-44 keys in a horizontal ScrollViewer, and it was wrong twice over. A pan and a press
begin with the same few pixels of travel, so a thumb coming down at an angle — which is how a thumb comes
down — was read as the start of a pan and the press was never delivered; the row slid slightly instead.
And 30 tall is well under the 48 Android asks for, so the presses that did arrive were hard to aim. The
arrows, the keys pressed most and in sequence, were the ones parked past the right-hand edge.

Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it: they
divide the width evenly and add a second row when the set does not fit. That is not convention for its own
sake — a key whose position is fixed can be hit without looking, which is the whole point of a modifier row
used while reading the output above it.

So: two UniformGrid rows, every key an equal share of the width, nothing scrollable, 40 tall. Every key
kept. Arrows on the lower row where the thumb rests, reordered to ← ↓ ↑ → so the pairs that undo each other
are neighbours. It costs about 51 pixels of terminal, four or five lines, and that is the right trade: the
row that was cheaper did not work.

The keys also gain a pressed state, which they had none of. They are Focusable="False" so that using one
does not take the keyboard off the terminal, and a button that never takes focus never shows the adorner
Fluent would otherwise give it — leaving no feedback at all on a control whose only job is to be pressed.
Their looks move from assignments in the code-behind to Button.hotkey, which is what lets a style say
:pressed, and the Ctrl latch becomes a class rather than two brushes written by hand.

── and five smaller ones ────────────────────────────────────────────────────────────────────────────────
The keyboard's inset was never cleared when the app was resumed without the keyboard it was applied for.
Android takes the keyboard away as the activity stops and Avalonia's input pane raises no StateChanged,
the pane going with the window rather than closing in front of one — so the interface came back holding a
whole keyboard's height of empty canvas. 838 of 2340 pixels on a Pixel 5, on every screen, until something
opened and closed the keyboard. MainActivity now raises PhoneEnvironment.Resumed and PhoneShell re-asks the
pane. It asks rather than assuming zero: a permission prompt or the biometric sheet can hand the activity
back with the keyboard genuinely still up, and flattening the inset there would strand the focused box
under a real one.

TextBlock.detail never wrapped, where .body beside it always has. A block that neither wraps nor trims lays
out at its full single-line width and the parent clips it, with no ellipsis to say anything is missing — so
the add sheet read "A machine to connect to, filed under a group if you" and stopped. Which of the fifty-odd
.detail blocks overflows is a function of width and font scale rather than of the string, so this is a style
rather than an attribute on the handful visibly wrong on one phone. The nine that genuinely want one line
now say NoWrap beside their TextTrimming, which is the pairing that was always meant.

The two port boxes are NumericUpDowns and were the only unthemed controls on this head — Fluent's border and
fill, next to a TextBox.field sharing their row. App.axaml has styled NumericUpDown for the desktop all
along; this head never had one.

Three buttons classed "icon accent" were painting Fluent's own Button.accent, #6275AC, a blue in no palette
here. The desktop overrides that style and this head did not, so the same class read correctly there and not
here.

The host key sheet said "First contact with 10.0.2.2 · 2222 . Nothing in this keychain…" — Avalonia puts a
space between adjacent Runs and the last one began with the full stop. A gap before a full stop, on the one
dialogue whose whole job is to be read carefully before it is answered.

── checks ──────────────────────────────────────────────────────────────────────────────────────────────
manual-checks gains 10.4a and 11.10f. 10.4 already covered dismissing the keyboard with back or the
chevron but not leaving the app with it up, which is the path the inset bug shipped through; 10.4a covers
both directions, since a fix that flattened the inset on every resume would pass one half and fail the
other. 11.10f guards the keypad against a ScrollViewer being put back around it.
2026-08-26 13:28:59 +02:00
jaap-jan 91f7586b22 Let the Android head finish signing in against the development realm
The head redirects to dev.dodotech.dodossh:/callback — a private-use scheme, RFC 8252 §7.1, matching the
intent filter on MainActivity — and this realm listed only the desktop's loopback. So a sign-in started
from a phone reached Keycloak and was refused there, with invalid_redirect_uri and nothing wrong on the
phone at all. The realm simply had never been told the second head exists.

Found by running the head against a local stack rather than by reading it: the desktop's own sign-in has
always worked here, which is exactly why this went unnoticed.

Note for anyone picking the change up: --import-realm skips a realm that already exists, so a restart does
nothing and the old configuration goes on being served. The container has to be recreated. The steps are in
the comment on the keycloak service in docker-compose.dev.yml.
2026-08-26 13:28:59 +02:00
jaap-jan c859705b83 Give the hosts board its vaults, and the Hetzner import a vault and a key
ci / android head (push) Successful in 4m1s
ci / build and test (push) Successful in 6m25s
ci / desktop nightly (push) Successful in 2m4s
ci / api image (push) Successful in 23s
Six things, all of them answers to a question a screen was deciding on
somebody's behalf.

The board draws a heading per vault and sorts each vault's hosts by that
vault's own groups. A group is an item of one vault, so drawing every
group of every vault in one run flattened away containment that already
exists: two vaults each holding a "production" were two headings with the
same word on them, told apart by a small grey name beside each. Single-
vault sessions are unchanged. There is now one "No group" heading per
vault, so the folded set is keyed on SidebarGroupHeader.CollapseKey
rather than on GroupId ?? Guid.Empty, which had one vault's unfiled hosts
folding every other vault's away with them.

A Ctrl-click starts its set with the card the board has lit. The board
always has a selection — a fresh unlock lights the first host so CONNECT
has something under it — so "one card lit, nothing ticked" is the state
this screen opens in, and ticking a second card there produced a set of
one: the machine the user was looking at was left out of the count and
out of everything Delete, Move and Change group then ran over.

The card tooltip says the host's name as well as its address. 214 pixels
of card trims the name, and the tooltip was the one place it was not said
in full.

The card's floor drops from 120 to 104. A card with no tags measures
about 87, so the mock's number was thirty pixels of air under every card
on a board that is scrolled.

"Move to another vault…" joins the board's context menu for one host.
Moving one machine meant opening its pane and finding the menu inside it,
while moving six was a right click away — the one-host case was the
harder of the two.

And the Hetzner import asks which vault and which key. A project belongs
to a team more often than to a person, so forty servers landing wherever
the session happens to be standing is the wrong default; ImportHostsAsync
takes the target vault, and the ssh_config importer passes null and is
unchanged. The key is the half Hetzner cannot supply and the keychain
usually already holds — the public half went to Hetzner when the servers
were made — so the import points every host at it rather than bringing
one. It defaults to no key rather than guessing, and says so when the key
it was given lives in a different vault from the hosts.
2026-08-26 11:54:29 +02:00
jaap-janandClaude Opus 5 ed936380b3 Give the user menu one door into settings instead of three
ci / desktop nightly (push) Failing after 34s
ci / build and test (push) Successful in 2m39s
ci / api image (push) Failing after 10s
ci / android head (push) Successful in 4m5s
Settings, Vaults and Preferences all opened the same full-window settings mode
and differed only in which of its seven pages they landed on. SettingsNav, which
lists every one of those pages including Vaults and Preferences, is on screen the
whole time you are in there — so two of the three rows were deep links into a
place the third one opens. They saved one click and cost a menu that read as
though it offered three destinations.

It was three rows for a reason that had stopped applying. Until v5c, "Settings"
and "Preferences" were two doors onto the same bare Preferences screen, and
splitting them was the fix for something real; Vaults arrived beside them when
VaultsScreen became a settings page too. Folding those two back in is not a
return to that state. They are pages now, with a rail of their own, and what a
menu at the foot of the nav rail is for is getting into settings at all.

Nothing became unreachable — both are one click deeper, from the rail that was
always going to be the way to them.

◆ WHAT STAYS, AND WHY IT IS NOT THE SAME ARGUMENT.

Logout stays and still lands on the Account page rather than on General, because
its confirmation card is drawn inline on that one page while the vault is
unlocked; arming it from anywhere else would raise a card nobody could see. The
vault switches and New vault above the divider stay too — neither is a page of
the settings mode, and the switches have no other home.

◆ THE ROWS ARE REMOVED FROM THE MARKUP, NOT UNWIRED.

NavRailTests asserts their absence rather than only asserting that Settings still
works, because the failure that guards against is silent: a row left in the
markup with its handler deleted is a menu entry that does nothing when pressed,
which reads as a broken application rather than a tidied menu. The helper it
asserts through excludes the vault switches by their data context and not by
their label — a vault is free to be called "Vaults", and a test that a menu entry
is absent must not be defeatable by what somebody named a vault.

Two facts go, one comes back plus the absence check; the layout suite is 166.

Manual check 1.8 walked a reader through all three rows in turn and now walks
them through the one, then through SettingsNav's seven — which is where the two
went. design-import-gaps.md gains a v5c-5 section rather than an edit to the v5c
table: what that table records is what shipped in that pass, and it did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:01:54 +02:00
jaap-janandClaude Opus 5 c270f3fe39 Import a Hetzner Cloud project's servers, and forget the token that read it
ci / build and test (push) Successful in 3m18s
ci / android head (push) Successful in 3m54s
ci / api image (push) Successful in 30s
ci / desktop nightly (push) Successful in 2m10s
An estate on Hetzner is already described somewhere: the project lists every
server, its addresses, its plan and where it runs. Getting that into a keychain
meant copying forty addresses out of a browser by hand, which is the same work
the ssh_config importer exists to remove for the machines described in a file.

◆ A WIZARD RATHER THAN A SCREEN, AND THE DIFFERENCE IS WHAT EACH STEP COSTS.

ImportScreen reads a file that is already on this machine, so it scans on open
and shows a list. This cannot: it needs a credential that has to be made in
another company's console first, and then it makes a request over the internet.
Those are two moments rather than one, and a screen that mixed the token box in
with the list would ask for the credential again every time somebody came back
to look at the list.

So three steps — token, choose, done — and the first two write nothing. Step two
carries one address choice, one login user and one port for the whole import,
because an estate is reached the same way throughout and a per-server dropdown
would be forty answers to one question.

◆ THE TOKEN IS USED ONCE AND CLEARED, INCLUDING WHEN IT WORKED.

The half worth stating is the success path. Clearing only on failure would leave
a credential capable of listing somebody's whole estate sitting in a text box for
as long as they spend reading forty rows, which is the longest anybody stands on
this screen. Nothing writes it to the vault, to settings or to a log, and
reopening the wizard restarts at step one rather than resuming — a resumed step
two would be a list nothing on screen has the means to refresh, going on looking
current for as long as the application stays open.

Read is the only permission it needs and the note above the box says so: a
Read & Write token would work identically here and would be a credential capable
of deleting the estate, pasted in for no gain. The note also says a token belongs
to one project — there is no API call that will say which, and none that lists
the others — so an account with four projects runs this four times. Without that
sentence the wizard looks like it lost most of the estate.

◆ THE ADDRESS IS A CHOICE, AND TWO OF THE THREE FALL BACK WHERE THE THIRD MUST NOT.

Public IPv4 and public IPv6 fall back to each other: the same machine over the
same internet, so the worst case is a route nobody picked, and the row says which.
Private does not fall back at all. A server on no private network is simply not
importable under that choice — its tickbox is disabled, and neither direction of
the header's tick-all can reach it — because falling back would put an address on
the open internet into a bookmark that was asked to stay off it.

IPv6 needs saying twice over. Hetzner routes a whole /64 and the API reports the
network, which nothing can dial; the address a stock image answers on is the
first one in it. That is a convention of Hetzner's own images rather than a fact
the API stated, so every use of it carries a warning on the row and in the stored
note, and it is computed by setting the low byte rather than by string surgery on
the prefix. It is not an edge case either — IPv4 is billed separately now and a
project built since then can be entirely IPv6-only.

◆ NOTHING IMPORTED HERE AUTHENTICATES, AND THAT IS SAID BEFORE THE BUTTON.

Hetzner has never held the private half of an SSH key. The API will name a
project's keys and hand over their public halves; there is no private half to
bring. So every host arrives bound to nothing and asks for a password until
somebody points it at a key already in the keychain — the same shape an
ssh_config import has with its key tick off, and stated above the button on the
same reasoning: somebody who is not told will conclude the import was broken.

What the note keeps is what does not go stale — the id, the plan, the location,
the image, the labels. The power state is a column and never a note: "running"
about a machine somebody switched off last March is worse than no note.

◆ WHERE IT LIVES: TWO FLAGS OVER ONE CONTENT COLUMN.

Drawn over the Preferences page inside settings mode exactly as ImportScreen is,
with SettingsNav unmoved and the titlebar offering "Back to preferences".
IsHetznerImportOpen is a second flag rather than a "which importer" enum, because
they are two independent facts about the content column and a single value would
need a "neither" member IsImportOpen already expresses; what keeps them from both
being true is that every path that opens one closes the other. The titlebar and
the Preferences page ask IsImporterOpen, which is written against both.

Preferences grows a second importer row beside the first rather than one row that
asks "from where?", which would put a question in front of the far more common
answer.

◆ WHAT IS NOT HERE.

Hetzner's Robot — dedicated servers and Storage Boxes — is a different API behind
a different credential, and nothing in this touches it. Said on the Preferences
row and on the token step rather than left to be discovered by an account that
has both.

And there is no phone layout. This one would work on Android, unlike the
ssh_config importer beside it: it is a token and a network request, the view
model is shared already, and scoped storage has nothing to do with it. What is
missing is a screen — the desktop draws a five-column table, and the phone
version of that is a different layout rather than the same one narrower. Recorded
in android-port.md and on the phone's own Preferences screen, because the note
there previously read as though no import of any kind could work.

◆ TESTS, AND WHAT IS LEFT FOR A PERSON.

HetznerCloudClientTests is 16 cases over the shapes the API actually returns,
against a stubbed HttpMessageHandler: paging followed, the token in a header and
never a query string, a refused token distinguished from a fault at Hetzner's
end, a token with a line break refused before anything is sent, the /64, the
private-network asymmetry, and a server the API described without a name.

ShellFlowTests.Hetzner.cs is 11 more against this fixture's real vault, real
cache and real server — split into a file of its own, as FakeVaultServer keeps
its team endpoints. What they establish beyond the reader's own suite is the half
it cannot reach: that reading stores nothing, that importing goes through the
ordinary create-and-push path and lands on the server with nothing pressing Sync,
that a server the keychain already has arrives unticked, and that the token does
not outlive the request.

Two layout cases measure both steps against the same content budget every other
settings page gets, which is also what proves the markup binds to names that
exist.

Manual checks 2.11a–2.11d are what no test in this repository can do, because
there is no account, no token and no network in the ordinary suite: a real
project listed against the real console, a wrong token, the private choice, and
what an imported host does when you connect to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:51:11 +02:00
laurensandClaude Opus 5 56c19b006d Stop the updater offering the build it saw first, whatever has been published since
ci / build and test (push) Successful in 3m28s
ci / desktop nightly (push) Successful in 1m22s
ci / api image (push) Successful in 21s
ci / android head (push) Successful in 3m41s
A client two versions behind was offered the older of the two, installed it,
restarted, and was told at once that another update was waiting. Two downloads,
two installs and two restarts to move one step.

The feed was not the problem and could not have been. The nightly release is
deleted and remade on every push to main, so it holds exactly one build: a client
can only ever be offered the newest thing there is. The wrong version came from
this application's own state.

◆ THE PASS STOPPED ASKING, WHICH IS NOT WHAT IT WAS MEANT TO STOP DOING.

    if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)

Ready means something is downloaded and waiting. The intent behind excluding it is
right — there is no point fetching what has already been fetched — and what it
actually did was skip the check. So from the first thing any process downloaded,
its banner was pinned to that build for the rest of its life, however many newer
ones were published afterwards. On a rolling channel that is the normal case
rather than a corner.

Not re-fetching is still the rule and now lives in FetchAsync, which compares
versions rather than refusing to look: the same build is left alone, a newer one
replaces what was pending. Which is also why FetchAsync answers with what is
*pending* rather than with what the check returned — a client on 1.0 holding 1.1
is told 1.1 by every later check, because 1.1 is still the newest thing on the
feed, so "the check found something" and "there is something new" are different
questions and only the second one is worth a download.

A build already fetched is never discarded, including when a check answers null.
Null means the feed's newest is no newer than the build that is *running*, which
says nothing about the one on disk — that one is still newer, and withdrawing an
offer somebody has already been shown would be the worse answer.

◆ AND A LAST LOOK BEFORE THE PROCESS IS REPLACED, WHICH IS THE HALF THE TIMER
  CANNOT REACH.

The loop asks every six hours. The build that caused this was published minutes
before RESTART NOW was pressed, so no interval anybody would accept would have
caught it. The press now asks once more.

It costs one small request in the ordinary case, because the feed answers with the
build already on disk and nothing is fetched. It costs a download only when there
genuinely is something newer, which is the case where the alternative was two
downloads anyway.

Bounded at ten seconds, and the wait rather than the request: VelopackUpdateChannel
records that Velopack's check takes no token, so cancelling would be observed only
after the call had already returned. Ten seconds bounds a small JSON document
against a network that drops packets rather than refusing them; a refused
connection fails in milliseconds and never reaches it.

Nothing here refuses a restart. An unreachable forge, a look that ran out of time,
a fetch of the newer build that failed halfway — all leave the build on disk
untouched and still newer than the one running, so the restart goes ahead with it.
Hence swallowed rather than reported: Failed is for something a person is waiting
on an answer to, and this person is waiting on a restart they are about to get.

IsRestarting keeps the banner up through it. Everything the strip draws keys off
IsReady and the look moves the state off that, so without it pressing RESTART NOW
made the banner vanish with nothing on screen saying why, for as long as a 120 MB
package takes. It says what it is doing instead, percentage and all.

◆ THE CHANNEL HAD TO LEARN WHICH BUILD IT FETCHED, AS AGAINST WHICH IT LAST FOUND.

Required by both halves above rather than tidied along with them. Matched()
resolved an AvailableUpdate only against `found`, which a check overwrites — and
both changes make this application check while a build is already downloaded. A
check that lands a newer answer and then fails to fetch it, or is given up on
halfway, leaves the shell restarting with the build it already has and Matched()
throwing at it: the offer real, the package on disk real, and the only thing wrong
being that the channel had forgotten which one it was. A slow forge became a
refused restart.

So `fetched` is recorded after a download returns, tried before `found`, and
survives every later check.

Settings → General claimed checks start "a couple of minutes after launch", which
has been untrue since the first pass moved to launch. It now also says the loop
keeps asking while an update is waiting, because that is the behaviour somebody
reading that row would want to know about.

UpdateFlowTests goes from 21 to 28: the pass asks again and fetches once, the
pending build is replaced when the feed moves on, a superseded offer reappears
through a dismissal, the press installs what the last look finds, and the three
ways the last look can fail all still install what was already on disk. Check
16.7a is the manual half, needing two real releases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:14:09 +02:00
laurensandClaude Opus 5 1908189100 Make the terminal's right button copy and paste, and never open a menu
ci / build and test (push) Successful in 5m43s
ci / android head (push) Successful in 4m4s
ci / desktop nightly (push) Successful in 1m28s
ci / api image (push) Successful in 26s
Selecting output and right-clicking it opened the WebView's own context menu with
Copy on it, which is correct behaviour for a web page and wrong for a terminal.
What a terminal's right button has meant since the Windows console got QuickEdit,
and since PuTTY shipped, is this: with something selected it copies and drops the
selection, with nothing selected it pastes. No menu, no second click.

The gesture can only live in the page. The selection is drawn into a canvas the
host never sees, and once a terminal has focus the host's window receives no input
events at all — the same reason the Ctrl+plus chords are handled there.

◆ THE CLIPBOARD IS THE HOST'S IN BOTH DIRECTIONS, AND THE READING IS WHY.

navigator.clipboard.readText() is gated behind the clipboard-read permission in
every Chromium, and an embedded WebView with no PermissionRequested handler
answers a permission request by putting a prompt on the screen. Trading a context
menu for a permission dialog is not this change. So the page asks and the host
answers, exactly as requestFontSizeStep does.

Writing does not go the other way either, although navigator.clipboard.writeText
would have worked from the page on Windows: a clipboard that is the host's in one
direction and the page's in the other is two mechanisms to keep working across
WebView2, WebKitGTK and Android's WebView. The host has owned a clipboard delegate
since the keychain grew its COPY buttons, so each App.axaml.cs grows a
ClipboardReader beside its ClipboardWriter and MainWindowViewModel takes it.

Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard.

◆ Clipboard IS NOT Paste, AND THE DIFFERENCE IS THE BRACKETING.

Paste carries a snippet off the sidebar, and the page deliberately *types* a
single-line one rather than bracketing it, because bash marks a bracketed paste as
an active region and a snippet should read as though it had been typed. A clipboard
paste is the case those markers are for — readline uses them to decide it has been
pasted into — so Clipboard is always bracketed and never followed by an Enter. One
opcode for both would have needed a flag meaning "which of the two kinds of paste
is this", on a payload whose existing flag byte means something else.

Both client frames are dispatched before the session lookup, which is what keeps
the gesture working in a pane whose shell has just ended: squinting at the message
a remote left behind is exactly when somebody copies the line. Dispatch was past
MA0051's length once it grew a second such branch, so the frames no pump needs to
see moved into Announce() and the font-size chord went with them.

◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.

preventDefault alone suppresses the menu and still lets xterm's own contextmenu
handler run, which moves its helper textarea under the pointer at z-index 1000 and
leaves the selection sitting in it, selected. Both of those exist to serve the
native menu. Taking the event in the capture phase on the pane and stopping it
there is what keeps the gesture wholly the page's.

rightClickSelectsWord is now false, which on macOS is a change of default rather
than a restatement of it: xterm sets it true there alone, so a right-click would
have selected the word under the pointer and copied that instead of pasting —
wrong on one platform only, which is the worst way for it to be wrong.

The renderer harness grows the pane it has been inviting since
RendererReconnectionTests: a fake Terminal, the two addons, and a real UTF-8 codec,
because both directions run through the page's own TextEncoder and TextDecoder and
a Latin-1 stub would pass a test with an em dash in the selection. Its warning
about declaring names terminal.js declares now covers the half that does not
SyntaxError — a harness frame() was silently replaced by the page's frame() and
failed six tests on a type error a long way from the cause.

Manual checks 1.11 and 11.13 are what is left for a person: that a real contextmenu
event reaches a capture listener, that no native menu is drawn once it is
prevented, and that xterm's real paste() brackets. 11.13 also records what is
unverified — whether Android's WebView delivers a pointer's secondary click as a
contextmenu event at all. ClipboardReader is wired on that head regardless,
because the alternative is a head where the paste half silently does nothing and
nobody finds out; that head is not compiled here either, this machine having no
android workload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:46:36 +02:00
jaap-janandClaude Opus 5 2810f7c12b Stop the terminal's Enter key dismissing the keyboard it was typed on
ci / build and test (push) Successful in 2m51s
ci / android head (push) Successful in 3m29s
ci / desktop nightly (push) Successful in 1m18s
ci / api image (push) Successful in 28s
The first device run of ADR 0015 found the premise held and the field misconfigured
in a second way: the suggestion strip, autocorrect and predictive text were all
gone, and every Enter closed the keyboard. On a terminal that is every command, so
the phone asked for a tap between each one.

TerminalInputSurface had been left with Avalonia's default ReturnKeyType, and the
same OnCreateInputConnection that carries the inputType fix maps that default, on a
field that is not Multiline, to IME_ACTION_DONE:

    _ => options.Multiline ? 1073741824 : 6,   // 6 is IME_ACTION_DONE

Done means done. The keyboard performs the action and dismisses itself, which is
right for the text box the default was written for and wrong for a surface where
Enter is punctuation rather than submission.

TextInputReturnKeyType.Return is the member that maps to IME_ACTION_NONE — value 1,
so `returnKeyType - 1 => 0 => 1073741824` — leaving Enter a plain newline with no
action attached and no reason for the IME to hide.

◆ THE TRAP, WHICH IS THE ONE 11.10b ALREADY WARNED ABOUT FROM THE OTHER SIDE.

Multiline=true also reaches IME_ACTION_NONE, through that same default arm, and is
the obvious fix and the wrong one. It ORs TYPE_TEXT_FLAG_MULTI_LINE into InputType,
which moves it off 1 and silently fails the ShowSuggestions guard the whole ADR
turns on. Taking the obvious route to a working Enter would have handed the
suggestion strip back and looked like the premise had failed rather than like the
fix had. ReturnKeyType touches imeOptions only, which is why it is what is set.

Enter's byte was never the problem and is not what changed: IME_ACTION_DONE was
already synthesising KEYCODE_ENTER through AvaloniaInputConnection.PerformEditorAction,
so commands did run — the keyboard just left afterwards.

OnTextInput now also normalises line endings to CR. Belt and braces for the case
this change makes newly possible: a keyboard with no editor action attached may
commit "\n" as ordinary text rather than sending KEYCODE_ENTER, and a shell handed
LF for Enter looks like a shell that ignored the key. Normalising rather than
special-casing also stops a pasted "\r\n" running its line twice.

Check 11.10e is the regression test, and says in as many words not to fix a
recurrence with Multiline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:29:38 +02:00
jaap-janandClaude Opus 5 c0dd5a147e Give the phone's terminal its own keyboard, and stop refereeing a focus fight
ci / build and test (push) Successful in 3m9s
ci / android head (push) Successful in 3m43s
ci / desktop nightly (push) Successful in 1m22s
ci / api image (push) Successful in 26s
Typing into a shell on the phone has been the worst thing about this head, and
the reason is not the renderer. It is that the keyboard over the terminal was
configured by Chromium rather than by us: a prose keyboard, with a suggestion
strip, autocorrect, autocapitalisation and a predictive layer that inserts text
ahead of the cursor — over a surface where every character is a command.

The obvious fix does not exist. xterm.js already sets every hint the web
platform has on its helper textarea — autocorrect, autocapitalize and
spellcheck are all off in the vendored bundle — and Android ignores all three,
because they are advisory. What actually silences a keyboard on this platform
is an inputType carrying TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, which is what
Termux's own TerminalView sets and the reason typing in Termux feels like a
terminal. A page inside a WebView cannot reach that field: Chromium derives the
whole EditorInfo from the focused DOM node, and no attribute produces the
visible-password variation.

Avalonia can reach it, on the version already pinned here. Decompiled from
Avalonia.Android 12.1.1, AndroidInputMethod.OnCreateInputConnection:

    if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
        outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);

0x80080 is VISIBLE_PASSWORD | NO_SUGGESTIONS — Termux's pair exactly. So the
keyboard behaviour wanted is one attached property away from any Avalonia
control, and permanently out of reach of the WebView. That asymmetry is the
whole decision; ADR 0015 records it, along with the two stages deliberately not
taken and what would falsify the premise.

◆ THE INPUT PATH LEAVES THE WEBVIEW. THE RENDERER DOES NOT MOVE.

TerminalInputSurface is a 1x1 focusable control that draws nothing and is where
every character typed into a shell now arrives. It is not stretched over the
renderer, which would be the obvious shape and is wrong three times over: the
WebView composites above everything Avalonia paints so it would be invisible,
it would never see the touches the WebView consumes first, and it would sit
over the connect page sharing that Panel whenever no session is open. Focus
does not need area.

Its IME client retains nothing and claims nothing — no surrounding text, no
preedit — because a terminal has no document for an input method to edit. The
line being edited is on the far side of an SSH channel and this client has
never been told what is on it.

◆ THE FOCUS FIGHT ENDS BY REMOVING A CONTESTANT.

TerminalFocus is deleted rather than amended. It existed to hand Android's own
view focus back to the WebView after AvaloniaView.DispatchTouchEvent took it at
both ends of every touch, and it was correct for as long as the input
connection belonged to the page. It no longer does, so the focus the platform
hands to Avalonia's view is now the focus the keyboard wants. The accessory
row's two posted counter-moves go with it; the keys stay Focusable=false, which
is now what keeps Avalonia's focus on the input surface.

RendererFocus replaces it with the opposite move: the WebView is made
non-focusable, so it cannot take an input connection and Chromium's own
showSoftInput cannot succeed from it. Non-focusable is not non-interactive —
focusability governs keyboard input, not touch delivery — so the page keeps its
pan-y scrollback untouched. What it can no longer do is raise a keyboard, so
taps on it are observed with Handled=false throughout and reported, filtered by
the platform's own ScaledTouchSlop. Without that filter, dragging the
scrollback would raise the keyboard over the output being scrolled to.

◆ TWO THINGS FOUND ON THE WAY, ONE OF THEM OLD.

The Ctrl latch never worked with the software keyboard. Not a regression and
not new — everything typed went straight into the page and never passed
SendAsync, which is where the latch lives, so `ctrl` then `c` was inert unless
the second key also came from the accessory row. Routing input through Avalonia
fixes it as a side effect, and check 11.10d exists to keep it fixed.

Avalonia raises a text-input event for any key whose UnicodeChar is 32 or
above, whether or not the key event was handled — it reads the handled flag
only to fold into its own return value. Ctrl-C on a hardware keyboard therefore
produced 0x03 and then a literal c. controlByteAlreadySent swallows the second.
The keys in TryTranslate need no such guard, their UnicodeChars being below 32.

One deliberate loss, in the open: Delete sends backspace. Avalonia turns an
IME's deleteSurroundingText into a dispatched KEYCODE_FORWARD_DEL, so a soft
keyboard taking that route is indistinguishable here from a real Delete key —
and a backspace that deletes forwards on every phone is worse than a missing
forward-delete on the few devices that have one.

Nothing here is verifiable without a device, so the checks are written rather
than the tests: 11.10b is the falsification test and should be run first,
because everything staged on this stands or falls on whether one attached
property actually silences a real keyboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:12:14 +02:00
laurens c0396b4f4d Merge pull request 'Let the session address be selected, and copied whole' (#16) from claude/terminal-ip-selection-4db6cd into main
ci / build and test (push) Successful in 2m57s
ci / android head (push) Successful in 3m30s
ci / api image (push) Successful in 26s
ci / desktop nightly (push) Successful in 1m22s
Reviewed-on: #16
2026-08-24 13:41:32 +00:00
laurensandClaude Opus 5 06369274dc Let the session address be selected, and copied whole
ci / build and test (pull_request) Successful in 2m49s
ci / android head (pull_request) Successful in 3m10s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 5s
The address at the head of the session column is the one place the desktop
head prints the endpoint it actually dialled, and it was a TextBlock — which
in Avalonia is a drawing of a string rather than a string. The next thing
people do with an endpoint is paste it somewhere: into a second client, into
a ticket, into the ssh command being written in the window beside this one.
Retyping is how a digit goes missing from an address that is mostly digits.

SelectableTextBlock, which is what the rest of this application already
reaches for wherever a string is on screen to be taken away — a fingerprint,
a public key, a recovery code, a snippet's command. Nothing else about the
row moves: same font, weight, colour and trimming, same tooltip, same close
button beside it. The sidebar is one control drawn on both surfaces, so SFTP
gets it for the same reason it gets everything else in that block.

The trimming and the selection do disagree at the long end, and the fix for
that is not to drop the trimming: the ellipsis is there because a long
account@host:port would otherwise push the close button off the edge of a
panel whose whole point is that it can be got rid of. A drag reaches only the
glyphs that were drawn, so a trimmed address has to be taken with select-all
— and select-all takes the whole thing, because the ellipsis is a drawing and
not a shorter value. That is the half a test can quietly lose, so it is the
half the new test asserts, on the longest address the suite has rather than
on one that fits and would pass either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:18:49 +02:00
jaap-jan cfc2e3ae0c Notice when a file session stops, and give the file browser the four things it was short of
ci / android head (push) Successful in 3m33s
ci / desktop nightly (push) Successful in 1m26s
ci / api image (push) Successful in 24s
ci / build and test (push) Successful in 3m22s
The commit before this one taught a terminal to notice its machine going away.
The file browser had the same defect and three of the same symptoms, and it is
also the screen people leave soonest — so the disconnect work and four things it
was missing arrive together, because they are the same complaint: this screen
knows less about the host than the host does.

◆ THE DISCONNECT HALF, WHICH IS THE OTHER SURFACE OF THE LAST COMMIT.

IsConnected was set at connect and cleared only by the DISCONNECT button. A host
that went away therefore left this screen saying CONNECTED over a session that
was gone, and every action after it failing with the same server message and
nothing saying why they all did.

IRemoteFileStore now carries a latched TransportFailure and a ConnectionLost
event. The latch is the shell's own — read ISshShellSession.TransportFailure for
the argument, which is unchanged. The event is not, and it is the part this
surface needs: a file browser is idle between operations, so without something
raised at the moment the transport fails, nobody finds out until they click. A
bucket implements both and raises neither, which is honest rather than a gap:
S3 is request-per-operation with no connection to lose.

  - THE LISTING STAYS ON SCREEN, and that is the half that makes the rest worth
    having. SftpEntry is a record of what the server said rather than a handle,
    so the pane can go on showing where the user was — which is what RECONNECT
    is offering to put them back into. That needed a new ShowsRemoteContents on
    both heads: every "is there anything here" binding read IsConnected, which
    was right while the only way to stop being connected was to press a button
    that empties the pane, and wrong the moment a session could be lost.

  - RECONNECT re-dials through the ordinary connect path — same host key gate,
    same refusals, same log entry — and then walks back to the directory the
    pane was showing. The path is read off RemotePath rather than captured,
    because the pane already keeps it up to date and a copy is a second thing to
    get wrong. A host wanting a typed password is asked for one rather than
    dialled with an empty box.

  - SessionEndedBar is now one control on both surfaces, with the shell
    resolving which it is drawn over: a terminal has two endings to tell apart
    and a keychain entry to check, a file session has one ending and already
    captured what it would dial. The phone's Files screen has its own copy,
    bound to TransfersViewModel directly, because its DataContext is that rather
    than the shell.

◆ PERMISSIONS: THE PERMS COLUMN STOPS BEING READ-ONLY.

It has been a true fact about a file with nothing to be done about it since it
arrived, and the commonest next act after using this screen was opening a
terminal — a whole second authenticated connection — to chmod what had just been
uploaded.

Nine checkboxes rather than a box to type 755 into, because the mode is already
drawn as -rwxr-xr-x in the column the form was opened from: a who-can-do-what
grid is the same representation the reader is holding, where an octal field asks
them to translate something they can see into something they cannot check. The
octal is printed underneath as it changes, so the two readings check each other.
The EXEC column is headed "enter" over a directory, which is what that bit means
there and the commonest misreading of a POSIX mode. The listing is re-read after
APPLY, because a umask, an ACL or a filesystem mounted without permissions can
all land somewhere other than what was asked for.

Beside it, mode and mtime carried across a transfer — which is what makes an
uploaded deploy.sh arrive executable instead of with the server's umask on it.
THE MODE IS ONLY SENT FROM A PLATFORM THAT HAS ONE. A file on Windows has no
POSIX mode; this screen already says so by leaving the local pane's PERMS column
empty rather than filling it with a plausible -rw-r--r--, and inventing 0644, or
0755 for anything called .sh, would be this client deciding a fact about
somebody's file that nothing on the machine it came from ever said. The
timestamp goes either way, because every platform has one. A store with neither
refuses in its own words and is never asked.

◆ MULTI-SELECT: A TICK IS NOT THE LIST'S SELECTION.

The two answer different questions and the split is the whole design. The
selection is "the row I am looking at", which rename, move and permissions act
on — each takes a form naming one path and is one-at-a-time by construction. The
tick is "the rows I have gathered up", which download, upload and delete take,
and it survives the highlight moving.

With nothing ticked the batch falls back to the selection, which is what keeps
the commonest use of this screen from growing a step — the usual way a
multi-select makes a tool worse. Ticks are released once a set is queued, or a
second press sends everything again.

Deleting several names every path rather than a count, because agreeing to "3
items" is agreeing to a number, and this is the one act on this screen that
nothing walks back. Each goes on its own and a refusal does not stop the rest:
the host turns down a directory that still has something in it, and in a set of
five that must not silently cancel the four it would have removed — nor hide
which ones it did not.

A drawn checkbox rather than ctrl-click, and not for taste: the phone shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a
tap target there whatever the desktop does. One shape on both heads, and a state
somebody can see.

◆ RECURSIVE FOLDERS: THE TREE BECOMES FILES BEFORE THE QUEUE SEES IT.

A directory used to be counted and skipped. It is now walked, and the queue
still knows nothing about trees — which is what keeps every property it has:
each file resumable on its own, each written to a part file and renamed into
place, none able to overwrite anything already there.

The plan is collected whole and then queued, because directories have to exist
before the files that go in them and shortest-first is the only order that
guarantees a parent before its child. It also means a walk refused halfway has
queued nothing yet, so the sentence it produces describes one outcome rather
than an outcome and a queue that half happened.

Links are counted, not followed, in both directions. One pointing at an ancestor
is a walk with no end, and on the remote side there is a second reason: an lstat
listing says a name is a link and not what it leads to, so descending would be a
round trip per entry to find out. Locally that is AttributesToSkip on the one
enumeration that decides what to descend into. A 5000-file ceiling is a backstop
for the accidental drag of a home directory, and it says so rather than
truncating quietly.

The status line grew four more ways to be partly what was asked for, and states
each: gone, refused, left alone, not a file.

◆ EDITING IN PLACE: FETCH, OPEN, WATCH, PUSH BACK.

The round trip this replaces is download, find it, open it, edit, come back,
delete the old one because nothing here overwrites, upload, check the mode
survived.

DELIBERATELY NOT THROUGH THE TRANSFER QUEUE, in either direction. The queue
refuses to overwrite, which is the right rule for a file somebody dragged and
exactly the wrong one for a file they are editing, where replacing the original
is the entire intent. The write-back deletes first: OpenWriteAsync at offset
zero writes over the beginning and leaves any tail that is longer — its own
contract says so — which for a config that got shorter means a file ending in
the back half of its previous version. Deleting is the only truncation this
interface offers, and the mode goes back on afterwards because deleting took it.

A stat before every save, compared against what the server said when the copy
was taken, catches somebody else writing while the file is open — which is how a
config gets silently reverted. There is no merge and no button pretending
otherwise; the three answers offered are the three that exist. Length and
timestamp rather than a digest, because a digest is a second full read of a file
there is otherwise no reason to fetch.

Both platform halves are injected: what opens a .conf is a registry
association, a desktop entry or a Launch Services binding depending on the
desktop, and watching a file is a platform object with its own thread. The
watcher watches the directory and filters the name, because a great many editors
write a temporary file and rename it over the top — which to a watcher pointed
at an inode is the file disappearing. The phone gets neither delegate, so the
button is not live there rather than there and disappointing.

◆ TWO THINGS FOUND ON THE WAY, BOTH WORTH STATING.

The shell suite's inline `post` was not serialising the way a dispatcher does.
Routing TransfersViewModel through it exposed a check-then-add in
OnTransferChanged that two threads turn into two rows for one transfer. Harmless
in production, where the UI thread serialises — the fixture was under-specifying
what it stood in for, and now posts under a lock.

And the edit session replaced its own record with `with` on each save, which
left the watcher holding an instance that was no longer the current one: every
save after the first quietly did nothing. The stamp moves in place now, and the
test saves twice.

◆ WHAT IS VERIFIED.

901 across the five suites this touches, 19 of them new. The layout suite earned
its keep three times over: the batch deletion card's path list overflowed the
204-pixel pane at 120, at 72 and at 56 before 32 held, and the edit strip
overflowed by 131 until the whole strip — rather than the log inside it — was
bounded. Each number in those files is measured and says which failure it came
from, because the next person to add a line to either card will move them again.

What no suite covers is the platform halves of editing: Process.Start with
UseShellExecute and a live FileSystemWatcher are the operating system, and a test
waiting on one would be measuring it. The shell's own half — when a save is
written back, when it is refused, what stopping releases — is covered against a
watcher that fires on demand.
2026-08-24 11:48:23 +02:00
jaap-jan e2733e5cbc Notice when a shell ends, say which way it ended, and offer the way back
ci / build and test (push) Successful in 3m28s
ci / android head (push) Successful in 4m48s
ci / desktop nightly (push) Successful in 1m17s
ci / api image (push) Successful in 28s
A terminal whose machine rebooted stayed green for ever. Not "went stale and
recovered late" — for ever: the tab claimed CONNECTED, the elapsed timer went
on counting, and the only thing that ever corrected it was closing the
application. That is the defect this fixes, and it turned out to be two
separate failures wearing one symptom.

◆ THE HANG IS IN SSH.NET, AND KEEP-ALIVE ALONE WOULD NOT HAVE FIXED IT.

ShellStream ends a blocked read from exactly two places: its channel being
closed, and the session raising Disconnected. A shell that exits gives the
first. But Session raises Disconnected from one method only — OnDisconnectReceived,
for a server that politely says goodbye — and a socket that simply fails goes
down a different path entirely: RaiseError fires the error event, sets the
disconnecting flag and disposes the socket, without ever raising Disconnected.
So the stream was never disposed, its Monitor.Wait was never pulsed, and the
read blocked until the process ended. The transport was gone, the client knew
the transport was gone, and nothing woke the one thread that was waiting.

That is why the first attempt at a test for this sat at thirty seconds and
failed. Both halves are needed and neither is sufficient:

  - Keep-alive makes the failure HAPPEN. An idle SSH session is a socket
    nobody writes to, and TCP has no way to discover that the far end has gone
    without writing to it — nothing was asked, so nothing could fail. Thirty
    seconds, matching OpenSSH's own ServerAliveInterval examples. It is the
    write that matters and not a reply: SSH_MSG_IGNORE need not be answered,
    so this is traffic rather than a heartbeat with a deadline, and there is
    no timeout invented here to be wrong about a slow link.

  - Disposing the stream from the error handler makes it ARRIVE. Queued to the
    thread pool and not done inline, which is not tidiness: this runs on
    SSH.NET's message-listener thread, and closing a channel waits for the
    server's close — processed by that same thread — so an inline dispose is
    the listener thread waiting on itself.

SFTP gets the keep-alive too. Its operations are request/response and bounded
by ConnectionInfo.Timeout already, so it needs no equivalent wake-up.

◆ exit AND reboot ARE TOLD APART BY A LATCH, BECAUSE THE OBVIOUS WAY IS A RACE.

Wait for the read to end and then ask whether the transport is still up, and
against a real server the answer is wrong about half the time: this was the
only channel on the connection, so sshd hangs up a millisecond after closing
it. By the time anything could ask, the transport is gone either way — and a
poll would report every ordinary `exit` as a dropped connection.

So ISshShellSession.TransportFailure is set at the moment SSH.NET raises the
failure, which its own ordering puts strictly before the disconnect that wakes
the read. A read that has just returned zero consults a settled value: set if
the transport failed, still null if the remote merely closed the channel. The
pump turns that into a TerminalSessionOutcome — RemoteClosed, ConnectionLost,
ClosedLocally — carried on SessionEnded, which used to hand over a session id
and nothing else. A subscriber cannot work this out afterwards; by the time it
hears, there is no transport left to interrogate.

Deliberately no fourth value for "the remote rebooted". Nothing on the wire
distinguishes a reboot from a cable being pulled, and a name this client
cannot check would be a guess dressed as a fact.

◆ WHAT IS DRAWN, AND WHY IT IS NOT DRAWN OVER THE PANE.

The status bar says DISCONNECTED or SHELL EXITED where it used to say NOT
CONNECTED for both — a word that is honest about a tab still dialling and
useless about one whose shell has stopped. Red for the first and grey for the
second: one is a machine that stopped answering and the other is work
finished, and crying wolf on the ordinary end of every session is its own bug.
The tab strip's dot gains the same fourth state, on both heads.

Under the terminal, a strip carrying the reason and two buttons. It takes its
own Auto grid row rather than overlaying the renderer, and that is forced
rather than chosen: the WebView is a native child view composited above
everything Avalonia draws, so the alternative is collapsing the terminal — and
the scrollback is precisely what the user wants to read, because the last
thing the remote said is usually why it went. terminal.js already writes the
same sentence into the pane; this is the copy that does not scroll away.

RECONNECT reuses the tab in place, which is what the word means to somebody
who pressed it: the terminal they were working in comes back, where it was.
It dials through the vault's ordinary connect path, so every refusal — a key
that has left the keychain, an unknown host key, a group default that has
changed since — is decided in the one place that already decides them; what
this adds is which tab the attempt belongs to. The old session is closed as
part of it, because a dead shell still holds an authenticated connection this
process has stopped reading, and its pane still holds an xterm, a scrollback
and a WebGL context, of which a browser hands out about sixteen.

Two refusals it makes rather than pretending: a host reached by typing an
address has no keychain entry to redial, and one that wants a password typed
goes to the keychain with the sheet raised rather than being dialled with an
empty box. Both say so on the strip where the button was pressed, not only in
the vault's status on a screen that surface is not.

TRY AGAIN appears on a refused connection for the same reason, and it is worth
as much: the commonest refusal is a host that was not up yet.

◆ WHAT IS ACTUALLY VERIFIED.

Two tests against a real sshd in a container. `exit` is reported as the remote
closing the session — the one a polling implementation fails, and a fake shell
cannot show it because a fake has no server behind it deciding to hang up. And
a live shell losing its transport is reported as a lost connection, produced
by dialling through a plain TCP relay the test owns and then cutting it; the
container is shared by the collection, so killing sshd was never an option.

The first draft of that second test used LoopbackProxyTests' SOCKS forward and
timed out: disconnecting the carrier of a ForwardedPortDynamic does not close
the loopback socket it accepted. Recorded in the test, because it is a
property of SSH.NET's forwarder rather than of anything here, and it is not
what a dying machine looks like.

What no test reproduces is the waiting. A relay that closes sends a FIN, so
the socket reports itself at once; a machine switched off sends nothing, and
finding that out is the keep-alive's business and the OS's retransmit budget's
— neither of which a test can hurry along without a clock this code does not
own. A reboot is prompt in practice because the machine comes back and RSTs.

Beside those: three pump outcomes, three workspace announcements, and seven
shell-flow tests covering the drop, the clean exit, reuse of the tab, retry
after a refusal, and both ways a reconnect can honestly come to nothing.

Also fixed in passing: SshNetShellSession.IsOpen read ShellStream.CanRead,
which is documented as always true — the stream stays readable after disposal
so buffered output can be drained — so it had answered "yes" for every shell
that ever existed. It reads CanWrite now. And MainWindowViewModel's session-
ended handler moved from Dispatcher.UIThread.Post to the injected post
delegate, which is what makes any of the shell half testable at all; the
remark listing the three unreachable posts is one shorter.
2026-08-24 10:36:15 +02:00
jaap-jan c2c860b4f2 Give the seated user the TPM, so nobody has to join the tss group
ci / build and test (push) Successful in 3m0s
ci / android head (push) Successful in 3m24s
ci / api image (push) Successful in 24s
ci / desktop nightly (push) Successful in 1m4s
ADR 0007 decision 4 refused this a commit ago, on the grounds that widening
access to the machine's TPM is a system-wide decision an application package
should not make for an administrator. That refusal is overturned here, and the
reasoning it is replaced with is narrower rather than louder — because the
refusal had treated "widen access" as one thing when it is two.

Adding a person to `tss` is what the refusal was really about, and it stays the
wrong answer: permanent, effective over SSH, applying whether or not they are at
the keyboard, and needing an administrator and a fresh login before the feature
appears at all.

Tagging /dev/tpmrm0 with `uaccess` is a different thing. It is systemd's own
mechanism for this exact question — the one /dev/dri, webcams, scanners and FIDO
keys already use — and logind grants the ACL to the user of the *active local
session*, then takes it back when that session ends. Not over SSH, not on an
inactive VT, not to daemons. The rule changes neither the mode nor the group, so
everything that worked before still works and the ACL is layered on top.

tpmrm and never tpm, which is what makes it defensible at all: /dev/tpm0 is the
raw device and is exclusive, where /dev/tpmrm0 is the kernel's resource manager,
which virtualises handles and isolates callers precisely so that more than one
unprivileged program can use the chip.

◆ THE COST IS A DENIAL OF SERVICE AND NOT A DISCLOSURE, AND IT IS WRITTEN DOWN.

TPM objects are protected by their own auth values, so this lets nobody read
anybody else's sealed data. What it allows is *failed* attempts — and a TPM
answers enough of those by locking out everyone, which is the same anti-hammering
this project relies on to make a four-digit PIN safe. On a machine that unlocks
its disk with the TPM at boot, somebody sitting at it can trip that and stop the
next boot from unlocking until it recovers.

An administrator who does not accept that masks the file:

    sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules

which is the standard override, survives upgrades, and puts DodoSSH back to
behaving as though the machine had no TPM. That is in the rule's own comments,
because the person who needs it is reading the file rather than the ADR.

Numbered 71 because 60-tpm-udev.rules is what creates the node's ownership and
73-seat-late.rules is what turns the tag into an ACL; verified in that order on
Fedora 44, along with `udevadm verify` accepting the file and the mask being
reported as "masked by previous entry". %post reloads and replays it against the
tpmrm subsystem, because a rule that waits for a reboot is a feature that does
not work on the day it is installed.

Both channels install the same path deliberately: this is about the machine's
TPM rather than about either build, the file is byte-identical from both, and
giving each its own copy would mean removing one package silently revoked the
other's access.

Phase 19 grew a check that the ACL exists for the local session and does *not*
exist for an SSH one, and another that masking the rule really does put the
machine back — then tells you to undo it before the checks that follow.

Also recorded, after it cost five confusing failures: swtpm serves exactly one
client, so a shell readiness probe sitting on the port makes the whole device-key
suite fail with "The TPM closed the connection" and nothing pointing at why.
2026-08-22 20:38:22 +02:00
jaap-jan 4552538533 Let the Linux head check for updates, and seal its device key in the TPM
ci / build and test (push) Successful in 2m55s
ci / android head (push) Successful in 3m28s
ci / desktop nightly (push) Successful in 1m10s
ci / api image (push) Successful in 49s
Two things the RPM shipped without, and the reason they arrive together is
that both are the same question — what may this platform ask of the machine,
and what must it not ask of the user.

◆ UPDATES: THIS CLIENT CHECKS AND FETCHES, AND PACKAGEKIT INSTALLS.

ADR 0013 rule 11 said there was no updater at all, and that went too far. It
conflated noticing that a newer build exists with installing it: the second is
what the trust argument is about, and the first is not — a client that checks
a fixed address and tells you what it found has taken no capability from
anybody. Written as a flat refusal, the rule bought its safety by paying a cost
that was avoidable, and the ADR now says so in its own words.

So LinuxUpdateChannel reads the rolling nightly release, compares versions the
way rpm compares them, downloads the package, verifies its digest and hands the
file to PackageKit over gdbus. Polkit asks; the user may say no; nothing is
torn down before the answer, because ApplyingEndsTheProcess is false and the
shell already knows what that means — it is the phone's shape, and declining an
update must not cost somebody their open shells.

Three details that are load-bearing rather than incidental:

  - The comparison is rpmvercmp and not SemVer, because rpm is what will accept
    or refuse the install. Same argument ADR 0014 makes for versionCode. The
    installed side is read out of the rpm database rather than derived from the
    assembly, so the SemVer-to-rpm translation exists in exactly one place. All
    28 expected values in RpmVersionTests came out of rpm itself rather than out
    of a reading of the algorithm.

  - The digest is checked and is not a signature. Velopack verifies its own
    downloads and Android checks a signature; an RPM over HTTPS has neither. The
    manifest names a SHA-256 and a mismatch deletes the file rather than leaving
    something installable-by-hand in the profile directory.

  - ONLY_TRUSTED is cleared, which is what routes the request to polkit's
    package-install-untrusted action. The package is unsigned, so requiring
    trust would fail every update at the last step.

◆ THE DEVICE KEY: SEALED IN THE TPM, BEHIND A PIN THE TPM CHECKS.

ADR 0007's amendment is the bar: a gate inside the process is not a gate. The
key is sealed into a TPM 2.0 keyedhash object whose userAuth comes from a PIN,
so unsealing is a command the chip refuses; the blob is encrypted to the storage
hierarchy and is useless on any other machine; nothing here ever compares a PIN.

The sealed object deliberately carries no NoDA attribute, and that one omission
is the whole reason four digits is enough: every wrong answer advances the TPM's
dictionary-attack counter and the chip stops answering entirely once it trips.
Measured at four wrong tries against a software TPM, after which the correct PIN
gets nothing either. It is asserted by a test rather than left to a reading of
the template, because removing the attribute leaves every other assertion
passing.

This is weaker than Hello and Touch ID and the difference is invisible, so it is
written down in three places rather than smoothed over: those raise their prompt
from inside the platform and this one is a window we draw. The prompt is
deliberately plain for that reason — teaching people that a convincing DodoSSH
PIN box is a system dialog is the one thing this design must not do.

Rejected, each for failing the amendment: the Secret Service API, which has no
per-use consent and is DPAPI's shape exactly; fprintd, which answers a question
and holds nothing, so skipping the call skips the gate; and a polkit-gated root
helper, which would work and puts a privileged component in a package that
installs nothing setuid.

Availability is narrower than "has a TPM": on Fedora /dev/tpmrm0 is root:tss and
desktop users are not in tss, so the ordinary case reports unavailable and keeps
asking for the passphrase. Shipping a udev rule to widen that is refused — it is
the administrator's decision, not an application package's.

◆ WHAT IS ACTUALLY VERIFIED, SINCE THAT IS THE PART THAT USUALLY ISN'T.

This is the only device key store in the repository with real coverage, and it
is only possible because the PIN is a delegate and the TPM is a device: seal,
unseal, wrong PIN, lockout, decline and a foreign blob all run unattended
against a software TPM. Windows and macOS cannot be tested at all — their
prompts block a suite forever, which WindowsDeviceKeyStoreTests records.

TSS.Net's own TcpTpmDevice cannot reach swtpm: it opens the platform socket too
and exchanges a handshake only the Microsoft reference simulator answers, so
Connect() blocks with no error and no timeout. SoftwareTpm speaks the data
channel and nothing else.

The suite also lied once and was fixed: the lockout test trips shared state, and
without a DictionaryAttackLockReset in Dispose every later test got null from a
correct PIN — six of them assert null and passed for entirely the wrong reason.

End to end on a real Fedora 44: a genuine linux-x64 publish packages, installs,
and every DT_NEEDED in the installed tree resolves except liblttng-ust, which is
the one this spec deliberately excludes. What no runner covers is /dev/tpmrm0,
PackageKit and polkit, and Phase 19 grew five checks for exactly those.

Also fixed: the magic-bytes literal picked up raw control characters, which made
LinuxDeviceKeyStore.cs binary to git — no diffs and no line-ending normalisation.
2026-08-22 20:02:30 +02:00
jaap-jan 1bf79c7c26 Package the Linux desktop head as an RPM, and publish a nightly one
ci / build and test (push) Successful in 3m1s
ci / desktop nightly (push) Successful in 1m7s
ci / api image (push) Successful in 22s
ci / android head (push) Successful in 3m39s
The third desktop platform shipped as nothing: a checkout somebody built.
VelopackUpdateChannel already named the two platforms it serves rather than
negating the one it does not, so that a Linux package would be an addition
there rather than something that had silently half-happened. This is it, and
it is deliberately not a Velopack channel.

There is no updater in the package at all. On this platform dnf is already an
updater the user trusts, audits and controls, and a second one answering to
this project instead is the capability ADR 0013 rule 4 spends a page keeping
unsettable. The cost is real and is written down rather than glossed: nothing
tells a Linux user that a newer build exists, because there is no repository
to tell them.

Rule 3 is untouched. The build job packages the release-shaped RPM on every
main build and every tag and uploads nothing, exactly as it does for Windows
and macOS; the desktop job packages the nightly-shaped one and puts it on the
rolling nightly-desktop release beside the Windows feed. Two RPMs with
different names owning different paths cannot replace one another, so rule 9's
separations come almost free here — the profile split still belongs to the
build, through DodoChannel.

vpk verifies that what it packs actually calls VelopackApp.Build().Run(), and
nothing here does that for us, so scripts/ci-linux-rpm.sh makes its own
assertions: it asks the finished package what it requires, what it provides,
what it holds and what modes those carry. Two of those checks exist because
the first realistic payload tripped them.

  - .NET's libmscordbi.so links libmscordaccore.so. With bundled provides
    excluded — which they must be, or this package would advertise libcoreclr
    to the whole system — that produced a dependency no host on earth can
    satisfy. The requires exclusion is computed from the payload rather than
    listed, because a list of Microsoft's internal file names goes stale in
    the direction that yields an uninstallable package.

  - cp -a inherits the publish directory's modes, and off the wrong filesystem
    that meant a world-writable /opt/DodoSSH: any local account able to
    replace the SSH client every other account runs. For an application
    holding a team's credentials in plaintext that is the trust model walked
    around rather than attacked. Every mode is now set absolutely.

Verified as far as a runner can: both packages install side by side on Fedora
44, resolve webkit2gtk4.1 2.52.5 — the backend MainWindow records as measured
— keep separate launchers, icons and profiles, and dnf remove takes one tree
and leaves the other. 0.0.1~alpha.0.143 sorts below both 0.0.1 and the next
height. What no runner can answer is Phase 19, which is new and unwalked.

RHEL 9 is deliberately not claimed. It ships WebKitGTK's 4.0 line under
libwebkit2gtk-4.0.so.37, and an or-branch for it was written and then removed:
nobody has run the head there, and dnf refusing with "nothing provides
libwebkit2gtk-4.1.so.0" is a better answer than an install that draws a blank
terminal. Same sentence, and the same reason, as the missing Intel Mac.

x86_64 only, for that reason and not a different one.
2026-08-22 16:43:38 +02:00
jaap-jan 1970e20e56 Merge pull request 'Type a path on the SFTP pane, and put a file right without deleting it' (#15) from claude/dodossh-sftp-improvements-3b6305 into main
ci / desktop nightly (push) Successful in 48s
ci / build and test (push) Successful in 2m29s
ci / android head (push) Successful in 3m15s
ci / api image (push) Successful in 32s
Reviewed-on: #15
2026-08-21 12:03:50 +00:00
180 changed files with 26817 additions and 983 deletions

No files matched your search

+7 -1
View File
@@ -20,8 +20,14 @@
*.sql text
# Files that must keep LF regardless of platform — shell scripts run in Linux
# containers, and Dockerfiles are read by a Linux daemon.
# containers, Dockerfiles are read by a Linux daemon, and an RPM spec is a shell
# script in disguise: rpmbuild feeds its %install section to /bin/sh, where a
# carriage return at the end of a line is an argument rather than nothing. A udev
# rule is parsed by udev's own lexer, which treats a stray carriage return as part
# of the value it is reading.
*.sh text eol=lf
*.spec text eol=lf
*.rules text eol=lf
Dockerfile text eol=lf
*.Dockerfile text eol=lf
Caddyfile text eol=lf
+174 -3
View File
@@ -29,6 +29,18 @@ jobs:
build:
name: build and test
runs-on: [linux]
# ◆ A CEILING, BECAUSE THIS FILE HAD NONE AND A HUNG TEST RAN UNTIL SOMEBODY NOTICED.
#
# One test in DodoSSH.Client.App.Tests waited on a named pipe that, on Linux, nothing was ever going
# to answer — and it waited on a token with no timeout, so the job did not fail, it sat there. It was
# killed by hand after twenty minutes, having reported nothing but the eighteen suites that had
# already passed.
#
# Twenty-five minutes against a job that ordinarily takes three. It is not a performance budget and
# must not be read as one: a build that legitimately grows past this should have the number raised.
# What it buys is that the failure mode is a red job with a named step rather than a runner somebody
# has to go and stop.
timeout-minutes: 25
steps:
# act_runner runs every `uses:` action with node inside the job container, and this
# runner's image has none — the run died on the first line of actions/checkout with
@@ -220,7 +232,12 @@ jobs:
- name: build
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
# Tighter than the job's own ceiling, and deliberately so: a hang here is the likeliest of the
# lot — it is the only step running code written for this repository rather than a tool — and a
# step-level timeout names the step in the failure instead of leaving a reader to work out which
# of fifteen was still running. Ten minutes against a suite that takes two.
- name: test
timeout-minutes: 10
run: dotnet test DodoSSH.slnx --no-build --configuration Release
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
@@ -462,6 +479,55 @@ jobs:
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
# ◆ AND THE THIRD DESKTOP PLATFORM, WHICH IS THE ONE THIS RUNNER ACTUALLY IS.
#
# Same argument as the two steps above and with none of their cross-compiling: this job runs on
# Linux, so the linux-x64 publish here is a native one. What it proves that the others cannot is
# the linux-x64 restore graph — libsodium, SkiaSharp and HarfBuzzSharp all ship native assets per
# RID, and a package that resolves for win-x64 and has no linux-x64 asset fails here.
#
# The runner is still Alpine and the package targets glibc distributions, which is fine and is not
# glossed over: scripts/ci-linux-rpm.sh explains why rpmbuild composing an RPM for one libc on a
# host running another is correct rather than lucky.
#
# ◆ IT IS ALSO WHERE THE PACKAGING ITSELF IS PROVED, WHICH ON THIS PLATFORM IS NOT A FORMALITY.
#
# The Windows and macOS steps hand a directory to vpk and vpk decides what the package needs.
# There is no vpk here — Velopack's Linux target is an AppImage and this is a distribution
# package, updated by dnf rather than by the application — so the dependency metadata is
# generated by rpm from the published tree, and scripts/ci-linux-rpm.sh interrogates the result
# rather than trusting an exit code. Every check in there is a way a package can be built
# successfully and then refuse to install on every host in the world; one of them was written
# because .NET's own libmscordbi.so links libmscordaccore.so, which is exactly that.
#
# Release-shaped rather than nightly-shaped — package name dodossh, tree at /opt/DodoSSH — so
# that what is proved on every main build is the packaging the release channel would use. The
# desktop job below builds the nightly-shaped one and is the only job that publishes either.
#
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
#
# RUNNER_TEMP, dying with the job. An RPM is if anything the easiest of the three to pass on
# casually — one file, no installer to click through, `dnf install` and done — which is a reason
# to keep it off a downloads page rather than a reason to put it on one. ADR 0013 rule 3.
- name: publish and package the linux desktop client
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
# RestoreLockedMode=false for the RID, exactly as the two publishes above do — see the long
# note on the win-x64 one. This checkout is thrown away, so the lock files it rewrites go
# nowhere.
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
-p:RestoreLockedMode=false \
--output "$RUNNER_TEMP/linux-x64"
scripts/ci-linux-rpm.sh \
--payload "$RUNNER_TEMP/linux-x64" \
--version '${{ steps.winpack.outputs.packVersion }}' \
--channel release \
--out "$RUNNER_TEMP/linux-rpm"
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
# server through Testcontainers and runs the API as a child process — so it needs a
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
@@ -507,6 +573,8 @@ jobs:
exit 0
android:
name: android head
# See the build job. The docker pull and the toolchain image make this one legitimately slower.
timeout-minutes: 40
runs-on: [linux]
# Writes the nightly release at the end of the job; see that step for what the capability is and why
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
@@ -866,7 +934,10 @@ jobs:
# never made.
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
for name in $names; do
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
# grep without -q, which has the same exit status and reads to the end of its input. A
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
echo "The release was created but $name is not on it:" >&2
printf '%s\n' "$published" >&2
exit 1
@@ -877,6 +948,8 @@ jobs:
desktop:
name: desktop nightly
# See the build job. Two publishes, a pack and an upload of about 120 MB.
timeout-minutes: 40
# Gated on the tests, like the image job and for a stronger version of its reason: this one is
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
# commit whose suite was red.
@@ -1048,6 +1121,45 @@ jobs:
ls -la "$RUNNER_TEMP/nightly-releases"
# ◆ AND THE LINUX NIGHTLY, WHICH IS A PACKAGE RATHER THAN A FEED.
#
# Everything above this step is Velopack: an installer, an index file and a client that polls it.
# This one is an RPM, and the difference is not a gap in the work. VelopackUpdateChannel refuses
# every platform that is not Windows or macOS — deliberately, and its comment says why — so a
# Linux build has no self-updater to give a channel to. What it has instead is dnf, which is a
# mechanism the user already trusts and already controls, and build/linux/dodossh.spec argues
# that at length.
#
# So there is no rolling *feed* here, only a rolling *file*: the newest nightly RPM sits on the
# same release as the Windows nightly and somebody who wants it downloads it. That is a worse
# deal than the Windows nightly gets and it is the honest one — the alternative is a package
# repository, which is a deployment this project does not have.
#
# DodoChannel=nightly for the same reason the win-x64 publish above sets it: it is what
# ClientPaths reads to suffix the profile directory, so the nightly RPM and a release RPM
# installed beside it share no cache, no outbox and no device key. The package name, the tree
# under /opt and the desktop entry are the packaging half of that separation and are the
# script's `--channel nightly` — see the note there for why it is four things and not one.
- name: publish and package the linux nightly
id: linuxpack
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
-p:RestoreLockedMode=false \
-p:DodoChannel=nightly \
-p:MinVerVersionOverride="$VERSION" \
--output "$RUNNER_TEMP/nightly-linux-x64"
scripts/ci-linux-rpm.sh \
--payload "$RUNNER_TEMP/nightly-linux-x64" \
--version "$VERSION" \
--channel nightly \
--out "$RUNNER_TEMP/nightly-rpm"
# ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
#
# Whoever can write a release here can put a build on every nightly desktop, because Velopack fetches
@@ -1138,17 +1250,76 @@ jobs:
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
# grep without -q, which has the same exit status and reads to the end of its input. A
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
echo "The release was created but $name is not on it:" >&2
printf '%s\n' "$published" >&2
exit 1
fi
done
echo "Published nightly desktop $VERSION as $TAG"
# ◆ THE RPM GOES ONTO THE RELEASE vpk JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
#
# 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
# 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.
#
# It has to happen here, after `vpk upload`, and not in a step before it. vpk deletes and
# recreates this release, so an RPM uploaded first would be attached to something 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)"
if [ -z "$release" ]; then
echo "The release was published and its id could not be read back:" >&2
printf '%s\n' "$published" >&2
exit 1
fi
# ◆ THE PACKAGE FIRST AND THE MANIFEST LAST, WHICH IS THE ORDER THE CLIENT DEPENDS ON.
#
# LinuxUpdateChannel reads linux-<channel>.json and then fetches what it names, exactly as the
# phone reads android-nightly.json. A manifest published before its package is a window in
# which every installed client is told to download something that is not there yet — and the
# client's answer to a missing asset is to throw, which a pressed CHECK NOW reports as a
# failure. Same rule, same reason, and the android job's comment says it first.
rpm='${{ steps.linuxpack.outputs.rpm }}'
manifest='${{ steps.linuxpack.outputs.manifest }}'
linuxNames=""
for file in "$rpm" "$manifest"; do
name="$(basename "$file")"
echo "Uploading $name ($(wc -c < "$file") bytes)"
curl -fsS -X POST -H "$auth" \
-F "attachment=@$file" \
"$api/releases/$release/assets?name=$name" >/dev/null
linuxNames="$linuxNames $name"
done
# Asked for back, exactly as the Velopack assets above are and for the same history: the
# android job once created a release, uploaded nothing, and reported success for every
# upload it never made.
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
for name in $linuxNames; do
# grep without -q; see the sibling loops above and scripts/ci-linux-rpm.sh.
if ! printf '%s' "$withRpm" | grep -F "\"name\":\"$name\"" >/dev/null; then
echo "The release was created but $name is not on it:" >&2
printf '%s\n' "$withRpm" >&2
exit 1
fi
done
echo "Published nightly desktop $VERSION as $TAG, with$linuxNames"
image:
name: api image
# See the build job. A cold layer cache is the slow case here.
timeout-minutes: 40
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
# clock on every main commit and buys the thing worth having: no image reaches the
# registry from a commit whose tests were red. An image is not a build artefact anyone
+8 -1
View File
@@ -87,9 +87,16 @@ Releases/
*.pfx
*.p12
*.snk
# Signing keystores, with one exception that is tracked deliberately. build/nightly.keystore is
# committed in the open on purpose — see ADR 0014 for why a key everybody holds is what makes the
# nightly channel safe to run in CI — and a file already tracked is unaffected by an ignore rule
# anyway. The negation is here so that reading this list does not suggest the tracked one is an
# oversight. The *release* keystore is a real secret under ADR 0011 rule 1 and must never land here.
*.keystore
*.jks
!build/nightly.keystore
appsettings.Local.json
appsettings.*.Local.json
.env
.env.*
!deploy/.env.example
secrets/
+22
View File
@@ -173,6 +173,28 @@
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
-->
<PackageVersion Include="Velopack" Version="1.2.0" />
<!--
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
Windows needs nothing here: CNG is in the framework and the Platform Crypto Provider is the TPM
behind it, and macOS reaches the Secure Enclave through the Security framework that
MacSymbols.cs binds by hand. Linux has neither — the platform's own stack is tpm2-tss, a C
library with a large surface — so this is the one head that needs a package to talk to its own
hardware.
Chosen over P/Invoking libtss2-esys for two reasons that both hold at the same time. It is
managed all the way down, so there is no native dependency to add to the RPM and nothing to
resolve at load time; and it works against a software TPM over a socket exactly as it does
against /dev/tpmrm0, which is what makes LinuxDeviceKeyStoreTests able to run the whole seal,
unseal and lockout path unattended. Neither of the other two device key stores can be tested at
all — see WindowsDeviceKeyStoreTests for the ten minutes that took to find out.
It carries a runtimes/linux-x64 asset, like SkiaSharp and libsodium, so the RID-specific publish
picks it up and the RID-free locked restore does not. See the RuntimeIdentifiers note in
DodoSSH.Client.App.csproj for why that distinction is load-bearing here.
-->
<PackageVersion Include="Microsoft.TSS" Version="2.1.1" />
</ItemGroup>
<ItemGroup Label="Versioning">
<!--
+116 -5
View File
@@ -69,9 +69,10 @@ src/
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked
DodoSSH.Client.Import reading ~/.ssh/config and the keys it names when asked; reading a Hetzner
Cloud project over its API
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
DodoSSH.Client.App the desktop head: its views, and its Windows integration
DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
DodoSSH.Client.Android the phone head: its views, and its Android integration
tests/ one test project per source project
docs/adr/ architecture decision records
@@ -81,7 +82,9 @@ docs/manual-checks.md what no test can reach, and what to look for when che
docs/android-port.md the Android head: what was decided, what is built, what is left
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
release-macos.sh — the same, signed and notarized, on a Mac
ci-linux-rpm.sh — builds the Linux .rpm, then interrogates it
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
build/linux/ the RPM spec the Linux package is built from
```
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
@@ -177,6 +180,74 @@ passphrase screen, always. The application lives at `/Applications/DodoSSH.Deskt
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
first never touches the second.
## Installing on Linux
An `.rpm` for x86_64, and the one platform where you get it from `main` rather than from a release — see
[the nightly build](#the-nightly-desktop-build) below, which is where the Linux package is published today.
```bash
sudo dnf install ./dodossh-nightly-*.x86_64.rpm
```
Everything above about where a client may come from applies unchanged. Four things differ, and the first
two are the ones to read before installing.
**It checks for updates and it installs nothing.** The Windows and macOS builds carry Velopack and replace
their own files; this one never does. It checks the project's forge every six hours, and when there is a
newer build it downloads it, checks its digest, and hands the file to **PackageKit** — which raises the
usual "authentication is required to install software" prompt. You may say no, and nothing is replaced until
you say yes. Nothing restarts either: replacing files under a running process is ordinary here, so the build
you have keeps running until you close it.
That split is the point rather than a compromise. Installing software is your package manager's job and it
already answers to you; noticing that a build exists is the part `dnf` cannot do for a package that is not
in a repository. What DodoSSH must never become is a second thing that can put software on your machine on
somebody else's say-so, which is why the address it checks is a constant in the binary and can never be a
DodoSSH server — [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) rules 4 and 11.
The gap that remains: there is **no package repository**, so `dnf upgrade` alone will never bring you a
newer DodoSSH. If you turn the check off on PREFERENCES → UPDATES, nothing else will tell you.
**Your PIN can stand in for your passphrase, if this machine has a TPM you can reach.** The device key is
sealed into the TPM and released only against a PIN that **the TPM itself checks** — it counts wrong answers
and stops responding after a few, which is what makes four digits enough and what makes a stolen disk
useless. Two honest caveats, both in
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4:
- **It is weaker than Windows Hello or Touch ID**, and the difference does not show. Those draw their prompt
from inside the operating system; this one is a window DodoSSH draws, so something already running as you
could imitate it. It could imitate the passphrase screen too, which is worth far more — but the guarantee
is not the same and this page will not pretend it is.
- **The package grants your session access to the TPM, and you should know what that means.** Fedora leaves
`/dev/tpmrm0` readable only by the `tss` group, so this would otherwise need an administrator and a fresh
login. Instead the package installs a udev rule tagging the device `uaccess` — the same mechanism your
webcam and your FIDO key already use, which grants an ACL to **whoever is logged in at the machine** and
takes it away when they log out. Not over SSH, not to a user switched away on another VT, not to daemons.
The cost is not secrecy — TPM objects are protected by their own PINs — it is that anyone at the keyboard
can make failed attempts, and a TPM answers enough of those by locking out *everyone*. **If this machine
unlocks its disk with the TPM at boot**, that means somebody sitting at it could stop the next boot from
unlocking until the lockout recovers. If you would rather not have that, one command puts it back:
```bash
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
```
DodoSSH then behaves as though the machine had no TPM: no PIN offered, passphrase every time.
See [docs/unlocking-without-the-passphrase.md](docs/unlocking-without-the-passphrase.md).
**It needs WebKitGTK, because the terminal is a WebView.** Specifically `webkit2gtk4.1`, which `dnf` pulls
in for you. Fedora 44 is the distribution the head has actually been run on. RHEL 9 and its rebuilds ship
the older 4.0 API line under a different library name and the package deliberately does not claim them —
`dnf` will refuse with *nothing provides libwebkit2gtk-4.1.so.0*, which is a better answer than installing
and drawing a blank terminal. This project does not ship desktop builds nobody has run, on any platform.
**Where things go.** The application at `/opt/DodoSSH`, launched as `dodossh` or from your menu; your vault
cache at `$XDG_DATA_HOME/dodossh`, usually `~/.local/share/dodossh`. Two different places on purpose:
`dnf remove` takes the first and never touches the second, so reinstalling asks for your passphrase rather
than starting over. Use **Sign out** inside the application to make the machine genuinely forget.
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
not only that the runners are Linux.
@@ -189,6 +260,18 @@ Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\Dodo
costs nothing you are relying on. It signs in separately and appears to your account as a new device,
because it genuinely is a second installation.
**The Linux `.rpm` is on the same release**, built from the same commit and versioned the same way, as
`dodossh-nightly`. It installs beside a release package for the same reasons and by the same four
separations — a different package name, a tree at `/opt/DodoSSH.Nightly`, a `dodossh-nightly` launcher with
its own menu entry, and a profile at `~/.local/share/dodossh.nightly`. Two RPMs owning different paths
cannot replace one another, so `dnf` will keep both rather than treating one as an upgrade of the other.
It is the only Linux package this project publishes at all today: there is no `v*`-tagged Linux release
yet, because no person has walked a Linux build through
[docs/manual-checks.md](docs/manual-checks.md). What CI proves on every push to `main` is that the release
package still *builds* — the same arrangement Windows and macOS have had since the beginning, where the
package is built on every commit and uploaded nowhere.
**Installing it means trusting everyone who can write a release on this repository, including CI.** The
updater applies what the feed serves without verifying a signature, so anyone who can change a workflow
file here can put a build on every nightly machine. That is an acceptable trade for a build you are trying
@@ -790,6 +873,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
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
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.
@@ -920,10 +1010,31 @@ keychain plus a terminal — and the spike that gates all of it.
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
[Installing on macOS](#installing-on-macos).
**The Linux half checks, and the package manager installs.** An `.rpm` for x86_64, built and checked on
every push to `main` and published to the nightly release, installing to `/opt` with a menu entry and a
`dodossh` launcher. It carries no Velopack and replaces nothing itself: it notices a newer build on the
project's forge, downloads it, verifies its digest and hands the file to PackageKit, which asks before it
installs. Splitting it that way keeps the part that can put software on your machine where it already
was — with your package manager — while fixing the half `dnf` cannot do for a package that is in no
repository. The device key is sealed into the TPM behind a PIN **the TPM checks and counts failures
against**, which is what makes four digits enough. Reaching the TPM needs no administrator and no group:
the package installs a udev rule tagging `/dev/tpmrm0` with `uaccess`, so the ACL follows whoever is
logged in at the machine and goes away with them — the same mechanism a webcam uses. What that costs, and
the one symlink that undoes it, is in the rule's own comments. See
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4,
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 11 and
[Installing on Linux](#installing-on-linux).
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the
manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
everything above is verified only as far as the bundle.
certificate is the first release aimed at strangers), macOS on Intel, a `v*`-tagged Linux release and a
signed package repository to serve it from, and Phases 18 and 19 of the manual checks — neither the macOS
nor the Linux build has been walked through by a person. There is no macOS runner in CI at all. The Linux
package is verified as far as what it *says* about itself — CI builds it and interrogates its
dependencies, its file list and its modes, and installs nothing — and the two pieces of Linux behaviour
that are code rather than packaging do have tests: the update channel's version comparison and digest
check run in CI, and the TPM store's seal, unseal, wrong-PIN and lockout paths run against a software TPM
anybody can start with one `docker run`. What no runner covers is `/dev/tpmrm0`, PackageKit and polkit,
which is what Phase 19 is for.
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
## Licence
+54
View File
@@ -0,0 +1,54 @@
# Lets the person sitting at this machine use its TPM, without joining a group.
#
# ◆ WHAT THIS CHANGES, AND WHAT IT DELIBERATELY DOES NOT.
#
# Fedora's 60-tpm-udev.rules leaves /dev/tpmrm0 as root:tss mode 0660, so using it means being in
# the tss group — which means an administrator, a usermod and a fresh login before DodoSSH will
# offer to seal a device key. This adds the uaccess tag, and nothing else: the mode and the group
# are untouched, so everything that worked before still works and an ACL is layered on top.
#
# uaccess is systemd's own mechanism for exactly this question and is what /dev/dri, webcams,
# scanners and FIDO keys already use. logind grants a POSIX ACL to the user of the *active local
# session* and takes it away again when that session ends. So:
#
# - somebody logged in at the physical machine gets access;
# - somebody logged in over SSH does not;
# - a user switched away to another VT does not;
# - a system daemon running as its own user does not.
#
# That is a much narrower grant than adding a person to tss, which is permanent, applies over SSH,
# and applies whether or not they are the one at the keyboard.
#
# ◆ tpmrm AND NOT tpm, WHICH IS THE WHOLE REASON THIS IS DEFENSIBLE.
#
# /dev/tpm0 is the raw device and access to it is exclusive — two users cannot share it, and a
# process holding it can leave the chip in a state that breaks everything else. /dev/tpmrm0 is the
# kernel's resource manager: it virtualises handles, swaps contexts and isolates callers from each
# other, and it exists *because* the TPM is meant to be usable by more than one unprivileged
# program. Granting a seat's user the manager is the use it was built for. Granting the raw device
# would not be, and this rule never names it.
#
# ◆ THE COST, WHICH IS REAL AND IS NOT ABOUT SECRECY.
#
# TPM keys are protected by their own auth values, so this grants nobody the ability to read
# anybody else's sealed data. What it does grant is the ability to make failed authorisation
# attempts, and a TPM answers those by counting them and eventually refusing everyone — the same
# dictionary-attack lockout DodoSSH relies on to make a four-digit PIN safe.
#
# On a machine whose disk is unlocked by the TPM at boot (systemd-cryptenroll, Clevis), a local user
# who trips that lockout can stop the next boot from unlocking until it recovers. That is a denial
# of service against the machine's owner by somebody already sitting at the machine, and it is worth
# knowing rather than discovering. An administrator who does not accept it can mask this file:
#
# sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
#
# which is the standard override and survives upgrades. DodoSSH then behaves exactly as it did
# before this file existed: no device key is offered, and unlocking asks for the passphrase.
#
# Numbered 71 because 73-seat-late.rules is what turns the tag into an ACL, and 60-tpm-udev.rules
# is what creates the device node's ownership in the first place. Anything outside that gap either
# never runs or runs too late to matter.
ACTION=="remove", GOTO="dodossh_tpm_end"
SUBSYSTEM=="tpmrm", KERNEL=="tpmrm[0-9]*", TAG+="uaccess"
LABEL="dodossh_tpm_end"
+342
View File
@@ -0,0 +1,342 @@
# ==================================================================================================
# The Linux desktop head, as an RPM.
#
# Built by scripts/ci-linux-rpm.sh, which is what supplies every dodo_* macro below and what checks
# the result. Nothing here is invoked by hand; `rpmbuild -bb` on this file with no --define set will
# fail on the Version line, which is the intended way round.
#
# ◆ WHY THIS IS AN RPM AND NOT A VELOPACK RELEASE, WHICH IS THE FIRST QUESTION.
#
# Both desktop platforms that ship today are Velopack: the client polls the project's forge, fetches
# a newer build and swaps itself over. ADR 0013 rule 4 is what makes that safe to hold — the feed
# address is a const, so no operator can answer "is there a newer version" on the user's behalf.
#
# This package replaces nothing and installs nothing, and that is deliberate rather than unfinished.
# On Linux the package manager is already an updater the user trusts, audits and controls, so putting
# a second one inside the application — answering to this project instead — would be building the
# capability ADR 0013 rule 4 spends a page making unsettable, on the one platform where nobody had to.
#
# What the client does do is the half a package manager cannot: LinuxUpdateChannel notices that a
# newer build exists on the project's forge, fetches it, checks its digest, and hands the file to
# PackageKit — which raises polkit and asks. The user may say no, and nothing is replaced until they
# say yes. See ADR 0013 rule 11.
#
# The consequence to know: discovery is worse here than on the other two desktops, because there is
# no package repository to subscribe to and the in-app check is what stands in for one.
# ==================================================================================================
# ◆ THE THREE THINGS THAT MUST BE TURNED OFF, AND WHAT EACH ONE WOULD DO.
#
# __os_install_post is the chain of brp-* scripts rpm runs over the buildroot: strip, strip-static,
# compress, elfperms. Every one of them is wrong here. The payload is a published .NET tree whose
# managed PDBs ADR 0013 rule 6 keeps on purpose — a stack with file names and line numbers is this
# project's entire diagnostic channel — and brp-strip would take the native symbols with them for a
# saving nobody asked for. It also runs the host's binutils over glibc binaries on a build host that
# is musl, which is a class of breakage with no good failure message.
#
# debug_package would then try to build a -debuginfo subpackage out of what strip extracted, from
# third-party binaries nobody here will ever step through.
#
# _build_id_links is the one that surprises people, because turning the other two off does not turn
# it off: rpm still writes /usr/lib/.build-id/<xx>/<hash> symlinks for every ELF file in the payload.
# For a bundled tree that means dozens of links in a system directory this package has no business
# owning, and two files with the same build id — which a .NET publish does produce — make it a build
# *warning* about duplicates rather than an error, so it goes unnoticed.
%global __os_install_post %{nil}
%global debug_package %{nil}
%global _build_id_links none
# ◆ AUTO-REQUIRES STAYS ON. AUTO-PROVIDES DOES NOT. THE ASYMMETRY IS THE WHOLE POINT.
#
# rpm's elfdeps reads DT_NEEDED and the symbol-version table out of every ELF file in the buildroot,
# and the requires half of that is worth much more than a hand-written list: it produces
# `libc.so.6(GLIBC_2.38)(64bit)` rather than `glibc`, so the package refuses to install on a host
# whose glibc is too old instead of installing and dying at the first launch. A hand list cannot
# express that and would go stale the first time a dependency changed underneath it.
#
# It reads ELF directly rather than by loading anything, so it does this correctly for x86_64 glibc
# binaries on a musl build host — which is what lets this package be built on the same Alpine runner
# as everything else in ci.yml.
#
# The provides half is the opposite. Everything under /opt here is a *bundled* copy: libSkiaSharp,
# libcoreclr, libe_sqlite3 and the rest belong to this application and to nothing else. Left on,
# this package would advertise `libcoreclr.so()(64bit)` to the whole system, and some other package's
# dependency could then be satisfied by a private copy inside a directory `dnf remove dodossh` takes
# away. Excluding the tree by path is the standard shape for that and costs nothing, because nothing
# outside this package is meant to link against anything inside it.
#
# ◆ WHICH IS WHY THE REQUIRES SIDE NEEDS AN EXCLUSION TOO, AND WHY IT IS COMPUTED.
#
# The two halves are not independent. A bundled library with a DT_NEEDED on another bundled library
# produces a requirement for a soname this package carries and — after the exclusion above — no
# longer advertises, which no host on earth can satisfy. It is not hypothetical and it is not rare:
# .NET's own libmscordbi.so links libmscordaccore.so, so the very first realistic payload this was
# tried against produced a package `dnf install` refuses outright.
#
# dodo_bundled is the alternation of every lib*.so* filename in the payload, worked out by
# scripts/ci-linux-rpm.sh from the tree it was handed rather than listed here. A list in this file
# would be a list of Microsoft's and SkiaSharp's internal file names, kept in step by hand, and its
# failure mode is the uninstallable package above.
#
# liblttng-ust is excluded on its own terms and not because it is bundled — it is not. It is what
# libcoreclrtraceptprovider.so links, and that file exists so that CoreCLR can emit LTTng trace
# events if a host has the tracing stack. Nothing here asks it to. The file is kept and the
# requirement is dropped, which is the arrangement that works both ways round: where liblttng-ust is
# present the dlopen succeeds and tracing is available, and where it is absent CoreCLR carries on, as
# it does on every machine that runs a .NET application without it. Requiring it instead would put
# the package out of reach on RHEL, where lttng-ust is in EPEL rather than in the base repositories —
# an install failure on a supported distribution, in exchange for a tracing integration this
# application does not use.
#
# scripts/ci-linux-rpm.sh checks the finished package against its own file list afterwards, because
# an exclusion that silently stopped matching would put the uninstallable package back with no
# warning anywhere.
%global __provides_exclude_from ^/opt/.*$
%global __requires_exclude ^(liblttng-ust\.so|%{dodo_bundled})
Name: %{dodo_name}
Version: %{dodo_version}
Release: %{dodo_release}
Summary: SSH client with an end-to-end encrypted credential vault
License: MIT
URL: https://git.dodotech.cloud/DodoTech-Public/DodoSSH
BuildArch: x86_64
# ◆ THE DEPENDENCIES elfdeps CANNOT SEE, WHICH ARE THE ONES THE PRODUCT IS MADE OF.
#
# Everything below is reached through dlopen, by a name composed at runtime, so it appears in no
# DT_NEEDED entry anywhere in the payload. Left to the automatic scan the package would install
# cleanly on a host with none of it and fail at the first window.
#
# GTK and WebKitGTK are the terminal. Avalonia.Controls.WebView P/Invokes a bare `libwebkit2gtk` and
# a bare `libgtk` and resolves them itself, and MainWindow.OnTerminalEnvironmentRequested documents
# the backend it was measured against: WebKitGTK 2.52.5 on Fedora 44, which is the 4.1 API line — the
# assembly's one fully-spelled soname, libjavascriptcoregtk-4.1.so.0, is that line's other half.
#
# ◆ THE 4.0 LINE IS DELIBERATELY NOT CLAIMED, AND THE COST OF THAT IS KNOWN.
#
# The control's own diagnostic says "webkit2gtk 4.0+", so a dependency permitting either line is
# tempting and was written first. RHEL 9 and its rebuilds are what it would buy: they ship
# webkit2gtk3, which is the 4.0 API under the soname libwebkit2gtk-4.0.so.37 — not .so.0, which is
# what makes this a thing to look up rather than guess. Requiring only 4.1 means `dnf install` on
# AlmaLinux 9 refuses with "nothing provides libwebkit2gtk-4.1.so.0", by design.
#
# It is refused because nobody has run it. This project does not ship an Intel macOS package for the
# same reason and says so in ADR 0013 decision 10 — the engineering is small and the verification is
# the scarce thing — and an install that succeeds and then draws a blank terminal is a worse answer
# than one that refuses in a sentence naming the missing library. The day somebody runs the head on
# a 4.0 host, this becomes a rich `or` and a line in the README.
#
# libX11 is Avalonia's own backend, P/Invoked the same way. On Wayland this is satisfied by XWayland,
# which every desktop shipping this package has.
#
# libicu is the one that looks removable and is not. DodoSSH.Client.App sets InvariantGlobalization
# to false — deliberately, and the csproj says why: this process prints timestamps and host names to
# a person who expects their own locale's conventions. A .NET process configured that way and given
# no ICU does not degrade to invariant, it refuses to start. The bare package name is the Fedora and
# RHEL spelling; it is the one name here that is a distribution's rather than a soname's, because ICU
# bumps its soname on every release and pinning one would make this package expire.
Requires: libwebkit2gtk-4.1.so.0()(64bit)
Requires: libgtk-3.so.0()(64bit)
Requires: libgdk-3.so.0()(64bit)
Requires: libgio-2.0.so.0()(64bit)
Requires: libglib-2.0.so.0()(64bit)
Requires: libgobject-2.0.so.0()(64bit)
Requires: libX11.so.6()(64bit)
Requires: libicu
# The size directories under /usr/share/icons/hicolor belong to hicolor-icon-theme and not to this
# package, which is the right way round — every application drops icons into them. Depending on the
# owner is how those directories come to exist at all on a minimal host, and how they get removed
# again when the last thing using them goes.
Requires: hicolor-icon-theme
# ◆ RECOMMENDED RATHER THAN REQUIRED, WHICH IS A JUDGEMENT ABOUT WHAT THE PACKAGE IS FOR.
#
# PackageKit is what LinuxUpdateChannel hands a downloaded update to, so without it the update path
# stops at the last step. It is not required, for two reasons that both point the same way: dnf
# installs weak dependencies by default, so the ordinary Fedora install gets it anyway; and a machine
# deliberately built without it — a minimal or an immutable one — should still be able to install an
# SSH client. The failure is not silent, which is what makes this safe: the channel names the missing
# service and the file it fetched, so the answer is one command the user was going to run anyway.
#
# Nothing is required for the TPM. LinuxDeviceKeyStore talks to /dev/tpmrm0 through a managed stack,
# so there is no library to depend on, and a machine with no TPM — or one whose device node this user
# may not open, which is the ordinary Fedora case — simply keeps asking for the passphrase.
Recommends: PackageKit
%description
DodoSSH is an SSH and SFTP client for teams whose credentials are shared and whose infrastructure is
not. Hosts, keys and passphrases live in a vault that is encrypted on this machine and stays
encrypted on the server, so the deployment holding a team's secrets can never read them.
This package carries its own .NET runtime. It checks the project's own forge for newer builds and can
download one, but it installs nothing itself: an update is handed to PackageKit, which asks before it
replaces anything. The address it checks is fixed and is never a DodoSSH server.
%install
rm -rf %{buildroot}
# ◆ /opt, AND NOT /usr/lib64/dodossh, WHICH IS THE MORE CONVENTIONAL PLACE.
#
# The Filesystem Hierarchy Standard reserves /opt for exactly this — an add-on package that arrives
# as one self-contained tree — and that is what a published .NET application is: a runtime, thirty
# managed assemblies and half a dozen native libraries that only make sense together and are only
# ever loaded by each other. Scattering them into the system library directory would mix a bundled
# libSkiaSharp in with the system's shared objects, which is the arrangement the provides exclusion
# above exists to keep this package out of.
#
# The directory name carries the channel, so a release and a nightly are two trees rather than one
# that overwrites the other. That is the same separation ADR 0013 rule 9 builds on Windows out of
# pack ids, arrived at here for free: two RPMs with different names owning different paths cannot
# replace one another, and dnf will say so rather than doing it. The profile directory is separated
# by the build itself — DodoChannel=nightly is what ClientPaths reads to suffix it — so the two
# installs share no state at all.
mkdir -p %{buildroot}/opt/%{dodo_dir}
cp -a %{dodo_payload}/. %{buildroot}/opt/%{dodo_dir}/
# ◆ AND THE MODES ARE SET HERE RATHER THAN INHERITED, WHICH IS NOT TIDINESS.
#
# `cp -a` preserves what the publish directory happened to have, and what it happened to have is a
# property of the filesystem it was written on rather than of anything decided here. rpmlint caught
# the consequence on the first tree this was tried against: every file in the package was mode 777,
# because the tree had come off a mount that reports everything that way.
#
# For this application that is not a lint warning, it is a hole. A world-writable /opt/DodoSSH lets
# any local account replace the SSH client belonging to every other account on the machine — the
# binary that holds a team's infrastructure credentials in plaintext while it runs, which is the
# thing ADR 0001 spends its whole trust model on. Malware running as nobody-in-particular would not
# need to attack the crypto; it would edit the client.
#
# Stated absolutely and in this order, so the answer does not depend on the publish at all:
# directories and the things that are executed or loaded get 0755, and everything else — assemblies,
# PDBs, the runtime config, the terminal's HTML and JavaScript — gets 0644. Owned by root, which is
# rpm's default for a package that names no %attr user, and is what makes the modes mean anything.
find %{buildroot}/opt/%{dodo_dir} -type d -exec chmod 0755 {} +
find %{buildroot}/opt/%{dodo_dir} -type f -exec chmod 0644 {} +
find %{buildroot}/opt/%{dodo_dir} -type f -name '*.so*' -exec chmod 0755 {} +
# The two files a .NET publish means to be executed. createdump is the runtime's crash-dump helper
# and is only present on some publishes, so it is conditional; the apphost never is, and the check
# above this section has already refused a publish without one.
chmod 0755 %{buildroot}/opt/%{dodo_dir}/DodoSSH
[ -f %{buildroot}/opt/%{dodo_dir}/createdump ] && chmod 0755 %{buildroot}/opt/%{dodo_dir}/createdump
true
# The apphost is the thing to launch and every other file in the tree is loaded relative to it, so
# what goes on PATH is a symlink rather than a copy or a wrapper script. .NET resolves its own
# directory through /proc/self/exe, which follows the link, so a symlinked apphost finds its runtime
# with no help — a wrapper exporting DOTNET_ROOT or cd-ing first would be solving a problem that
# does not exist here, and would be one more file to keep in step with the layout.
#
# Relative — ../../opt from /usr/bin — rather than the absolute /opt path it resolves to. Both work
# on a running system and only one works inside a chroot or an image being assembled somewhere other
# than /, which is where absolute symlinks out of a package are checked and warned about.
mkdir -p %{buildroot}%{_bindir}
ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
# ◆ THE DESKTOP ENTRY IS WRITTEN HERE RATHER THAN SHIPPED AS A SOURCE FILE.
#
# Three of its seven meaningful lines differ between the release and the nightly, and the two files
# that would result are the kind of pair that drifts: somebody adds a MIME type to one of them and
# nobody notices for a release. Composed from the same macros the rest of the layout uses, they
# cannot.
#
# StartupWMClass is the line that is easy to leave out and visibly wrong without: Avalonia sets
# WM_CLASS from the assembly name, which is DodoSSH for both channels, so without this a launched
# 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
# started it.
mkdir -p %{buildroot}%{_datadir}/applications
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
[Desktop Entry]
Type=Application
Version=1.0
Name=%{dodo_title}
GenericName=SSH Client
Comment=SSH and SFTP with an end-to-end encrypted credential vault
Exec=%{dodo_bin}
Icon=%{dodo_bin}
Terminal=false
StartupNotify=true
StartupWMClass=DodoSSH
Categories=Network;RemoteAccess;System;
Keywords=SSH;SFTP;Terminal;Remote;Shell;
DESKTOP
# One file per size into the hicolor theme, named for the package so the two channels show different
# entries rather than fighting over one path. The sizes are the ones the icon script draws and its
# comment argues for; adding one there means adding it here.
for size in 48 64 128 256; do
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps
install -m 0644 %{dodo_icons}/dodossh-${size}.png \
%{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps/%{dodo_bin}.png
done
# ◆ THE UDEV RULE, WHICH IS THE ONE FILE THIS PACKAGE PUTS OUTSIDE ITS OWN TREE.
#
# It tags /dev/tpmrm0 with uaccess so the person at the machine can use the TPM without being put in
# the tss group by an administrator. The rule itself carries the whole argument, including what it
# costs and the one-line way to mask it.
#
# Installed for both channels, and that is not a mistake to be tidied up. The two packages own
# different paths for the same reason ADR 0013 rule 9 gives, but this file is about the machine's
# TPM rather than about either build — a nightly and a release on one machine want exactly the same
# access, and giving each its own copy would mean removing one package silently revoked the other's.
# rpm refuses to let two packages own one path with different content, which is what makes this
# safe: the file is byte-identical from both, so it is shared rather than duplicated.
#
# /usr/lib/udev/rules.d hardcoded rather than %{_udevrulesdir}, which comes from systemd-rpm-macros
# and is not present on the Alpine host CI builds this on. Hardcoding a path that has been stable
# for a decade beats a macro that expands to nothing and silently installs into the buildroot's root.
mkdir -p %{buildroot}/usr/lib/udev/rules.d
install -m 0644 %{dodo_udev} %{buildroot}/usr/lib/udev/rules.d/71-dodossh-tpm.rules
# Installed rather than listed straight out of the source tree. `%license` is a %doc-style marker and
# resolves its argument against the build directory, which this spec does not have — there is no
# %prep and no %build, because the payload arrives already published. Putting the file where it
# belongs first and marking the installed path second is the form that works with neither.
mkdir -p %{buildroot}%{_licensedir}/%{name}
install -m 0644 %{dodo_licence} %{buildroot}%{_licensedir}/%{name}/LICENSE
# Modern rpm on Fedora fires file triggers that do both of these, so on the distribution this was
# measured against they are redundant. They are here for the ones that do not, and every line ends in
# `|| :` so that a host without the tool installed gets a package that installs rather than a
# scriptlet failure at the end of a 130 MB transaction.
%post
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
# Reloaded and replayed, because a rule that only takes effect at the next boot is a feature that
# does not work on the day it is installed — and "reboot and try again" is the worst possible answer
# to "why is the PIN option missing". The trigger is scoped to the tpmrm subsystem so this re-runs
# rules for one device rather than for every device on the machine.
/usr/bin/udevadm control --reload &>/dev/null || :
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
%postun
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
# On the way out too, so removing the package takes the ACL with it rather than leaving the last
# session's user holding access to the TPM until the next reboot.
/usr/bin/udevadm control --reload &>/dev/null || :
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
%files
# The whole tree, owned by this package, which is what makes `dnf remove` take all of it. The user's
# vault cache is not in here and is not touched by an uninstall — ClientPaths puts it under
# $XDG_DATA_HOME, which is the Linux spelling of the same decision ADR 0013's consequences record for
# Windows: the install moves, the profile does not, and an uninstall is not a sign-out.
/opt/%{dodo_dir}
%{_bindir}/%{dodo_bin}
%{_datadir}/applications/%{dodo_bin}.desktop
%{_datadir}/icons/hicolor/*/apps/%{dodo_bin}.png
/usr/lib/udev/rules.d/71-dodossh-tpm.rules
%license %{_licensedir}/%{name}/LICENSE
%changelog
# Deliberately empty. The changelog of record is the git history and the release notes on the forge,
# and a %changelog generated per build from a version number carries no information that `rpm -q`
# does not already answer — while a hand-written one is a third place a release has to remember to
# edit. rpmlint says W: no-changelog and that warning is accepted.
+6 -1
View File
@@ -1,7 +1,12 @@
# Development dependencies only: PostgreSQL and Keycloak.
#
# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the
# schema is still churning. The production stack is deploy/docker-compose.yml.
# schema is still churning.
#
# There is no production compose file in this repository. This one said there was, naming a
# deploy/docker-compose.yml that has never existed in any commit. What ships instead is the
# container image the CI image job builds and pushes; a compose file to run it belongs to the
# self-hosting guide in M4, and until that exists the honest thing to say is that it does not.
#
# docker compose -f deploy/docker-compose.dev.yml up -d
# dotnet run --project src/DodoSSH.Api
+2 -1
View File
@@ -26,7 +26,8 @@
"post.logout.redirect.uris": "http://127.0.0.1/*"
},
"redirectUris": [
"http://127.0.0.1/callback"
"http://127.0.0.1/callback",
"dev.dodotech.dodossh:/callback"
],
"webOrigins": [],
"protocolMappers": [
+19
View File
@@ -99,3 +99,22 @@ real ones.
- **Client-supplied target address.** One line of convenience, and the relay becomes an open
proxy into the operator's network.
- **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.
+80 -3
View File
@@ -107,6 +107,81 @@ almost nil. Two things to check when it is taken up: `EnrollmentValidation` pins
`DevicePublicKey` to `CryptoSpec.PublicKeySize` (32 bytes; a P-256 public key is 33 or 65), and the
envelope's minimum-length rule.
### 4. Linux: sealed into the TPM behind a PIN the TPM checks
Added after the Linux head was packaged ([ADR 0013](0013-desktop-distribution-and-updates.md) decision 11).
The third desktop platform reached this file the way the first two did — by having to answer the amendment
above — and it is the only one where the answer is not a clean yes.
**The mechanism.** The 32-byte device key is sealed into a TPM 2.0 keyedhash object whose `userAuth` is
derived from a PIN. Unsealing is a command the TPM refuses without it. The blob on disk is encrypted to the
machine's storage hierarchy, so it is useless on any other machine, and nothing in this application ever
compares the PIN. The parent is an ECC P-256 primary rederived from a fixed template on every use rather
than made persistent — a persistent handle would occupy an NV slot forever, need cleaning up on withdrawal,
and collide with whatever else on the machine chose the same index. ECC rather than RSA because creating an
RSA primary on a discrete TPM takes seconds and this runs on the unlock path.
**The sealed object deliberately does not carry `NoDA`, and that single attribute is what makes a
four-digit PIN defensible.** Every wrong answer advances the TPM's dictionary-attack counter and the chip
stops answering entirely once it trips — measured against a software TPM at four wrong tries, after which
the *correct* PIN gets nothing either. That is the property a short secret has here and cannot have in a
file, and it is asserted by a test rather than left to a reading of the template, because removing the
attribute would leave every other assertion in that file passing.
◆ **This is weaker than Windows Hello and Touch ID, and the difference is invisible to the user.** Both of
those raise their prompt from inside the platform — a secure desktop, a system sheet — so the process asking
never sees the secret. Linux offers nothing to borrow, so the PIN is typed into a window this application
draws, and an attacker already running as the user could draw a convincing copy of it. Two things bound
that, and neither erases it: the same attacker could counterfeit the passphrase screen, which is a much
larger prize, so the feature creates no capability that did not exist; and what they would obtain is a PIN
that is worthless without this machine's TPM, where the passphrase is worth every machine. **The parity is
not claimed anywhere in the interface**, which is why `DesktopDeviceKeyStores` documents the ordering and
why the prompt is deliberately plain rather than dressed to look like a system dialog — teaching people
that a convincing DodoSSH PIN box is a system dialog is the one thing this design must not do.
**Reaching the TPM at all is the other half, and the package now arranges it.** On Fedora `/dev/tpmrm0` is
`root:tss` mode 0660 and desktop users are not in `tss`, so without help the ordinary case is a working TPM
this process may not open — which reports unavailable and keeps asking for the passphrase, exactly as a
machine with no TPM does.
◆ **This originally said shipping a udev rule to widen that was refused, on the grounds that it is a
system-wide decision an application package should not make. That refusal was overturned deliberately, and
the reasoning it was replaced with is narrower rather than louder.** The refusal treated "widen access" as
one thing. It is two, and they are not close:
- *Adding a person to `tss`* is what the refusal was really about, and it remains the wrong answer. It is
permanent, it applies whether or not they are at the machine, it applies over SSH, and it needs an
administrator and a fresh login before the feature appears at all.
- *Tagging `/dev/tpmrm0` with `uaccess`* is the mechanism systemd already uses for `/dev/dri`, webcams,
scanners and FIDO keys: logind grants a POSIX ACL to the user of the **active local session** and removes
it when that session ends. Not over SSH, not on an inactive VT, not to daemons. `build/linux/71-dodossh-tpm.rules`
is that one line, and it changes neither the mode nor the group, so anything that worked before still does.
**`tpmrm` and never `tpm`, which is what makes it defensible at all.** `/dev/tpm0` is the raw device and is
exclusive; a process holding it can leave the chip in a state that breaks every other user. `/dev/tpmrm0` is
the kernel's resource manager, which virtualises handles and isolates callers — it exists *because* the TPM
is meant to be usable by more than one unprivileged program. Granting a seat's user the manager is the use
it was designed for.
**The cost is a denial of service and not a disclosure, and it is stated because it is easy to miss.** TPM
objects are protected by their own auth values, so this lets nobody read anybody else's sealed data. What it
does allow is *failed* authorisation attempts — and the chip answers those by counting them and eventually
refusing everyone, which is the same lockout decision 4 relies on. On a machine whose disk is unlocked by
the TPM at boot (`systemd-cryptenroll`, Clevis), somebody sitting at it can trip that lockout and stop the
next boot from unlocking until it recovers. An administrator who does not accept that masks the file with
`ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules`, which is the standard override, survives upgrades,
and puts the behaviour back to the passphrase-only path. The rule says so in its own comments, because the
person who needs to know is reading the file rather than this document.
**What was rejected, and why each would have failed the amendment above.** The Secret Service API —
gnome-keyring or KWallet — is out of process and OS-managed and has *no per-use consent*: once the session
keyring is unlocked anything running as the user reads the key silently, which is DPAPI's shape exactly and
what "Why not DPAPI alone" already refuses. `fprintd` is an authentication oracle rather than a key store:
it answers a question and holds nothing, so skipping the call skips the gate — the amendment's mistake with
a fingerprint reader attached. A polkit-gated root helper would genuinely work and was declined on surface
area: it puts a privileged component in a package that installs nothing setuid, to reach a property the TPM
already provides.
## Consequences
### The cache key had to move, and the spec changed
@@ -138,9 +213,11 @@ would have become false under DPAPI alone. A gesture is still something the atta
### Operational
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
is therefore required, not a nicety.
- **A TPM is not always there, and on Linux it is often there and out of reach.** A machine without one gets
a store that reports itself unavailable, so unlock keeps asking for the passphrase and neither affordance
appears in the interface. The passphrase path is therefore required, not a nicety — and on Linux it is the
default rather than the exception, because the device node is not readable by an ordinary desktop user
until somebody says it should be. See decision 4.
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
@@ -316,6 +316,106 @@ preferences screen, which is the screen somebody reads when asked which nightly
across the floor holds: heights rise within a floored version, and the first real tag moves past all of
them. The release script gets no floor and must not have one; there, `0.0.0` should be refused.
### 11. Linux is a third desktop platform, and it is a package rather than an updater
The Linux head existed and shipped as nothing: a checkout somebody built. `VelopackUpdateChannel` already
named the two platforms it serves rather than negating the one it does not, on the reasoning that "a future
AppImage should be an addition here rather than a thing that silently already half-happened". This is that
addition, and it is not an AppImage.
**The package is an RPM and nothing inside it replaces anything.** That is the decision, and everything
else follows from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been
repeated a third time — one more channel, one more index, one more client swapping its own files over. It
should not be, because this platform already has the mechanism that whole apparatus is a substitute for.
`dnf` is an updater the user already trusts, already audits and already controls, and it updates the
runtime's own dependencies besides. Adding a second one that answers to this project instead would be
building the capability decision 4 spends a page keeping unsettable, on the one platform where nobody had
to.
◆ **The first version of this rule said "there is no updater in it at all", and that went too far.** It
conflated two things the other two platforms happen to do together: *noticing* that a newer build exists,
and *installing* it. The second is what the argument above is about, and the first is not — a client that
checks a fixed address and tells you what it found has taken no capability from anybody. Written as a flat
refusal, the rule bought its safety by paying the cost in the paragraph below, and the cost was avoidable.
**So the split is: this client checks and fetches, and PackageKit installs.** `LinuxUpdateChannel` reads
the same rolling release the Windows nightly feed lives on, compares versions the way `rpm` compares them,
downloads the package, verifies its digest, and hands the file to PackageKit — which raises polkit and asks
the user, who may say no. `ApplyingEndsTheProcess` is false and nothing is torn down before the answer, which
is the phone's shape rather than the desktop's, and is already what `MainWindowViewModel` does with that
flag. Four things follow that are worth stating because each is a property the Velopack channels do not have
to think about:
- **The comparison is `rpmvercmp` and not SemVer.** Decision 7 made one version of record for the whole
repository, and this is the one place it is not the string that decides: `rpm` will accept or refuse the
install, so the client must compare the way `rpm` does or it can offer an update PackageKit then declines.
It is the same argument [ADR 0014](0014-android-updates.md) makes for comparing `versionCode`. The
manifest therefore carries both — the RPM version to compare and the SemVer to show a person — and the
installed side is read out of the rpm database rather than derived from the assembly, so the translation
`scripts/ci-linux-rpm.sh` performs exists in exactly one place.
- **The digest is checked, and it is not a signature.** Velopack verifies what it downloads and Android
refuses a package whose signature does not match the installed one. An RPM fetched over HTTPS and handed
to a package manager has neither, so the manifest names a SHA-256 and the client refuses anything else.
That catches a truncated download and a caching proxy and says nothing whatever about provenance, because
the same job writes the package and the manifest. Provenance here is decision 4's constant address.
- **`ONLY_TRUSTED` is cleared, deliberately.** The package is unsigned, so requiring PackageKit to trust it
would fail every update at the last step. Clearing the flag routes the request to polkit's
`package-install-untrusted` action, which asks for an administrator rather than proceeding quietly — the
user is told what they are agreeing to, which is the honest form of an unsigned package.
- **Nothing restarts.** Replacing files under a running process is ordinary on Linux and the old inodes stay
mapped, so the build keeps running until somebody closes it. That is the same courtesy decision 1's
consequences argue for on Windows, arrived at for free.
The honest cost that remains, recorded because it is the part that will be argued with: **discovery is still
worse here than on either other desktop, for anybody who declines the in-app check.** There is no package
repository to subscribe to, so `dnf upgrade` on its own will never bring a newer DodoSSH; the in-app check is
what stands in for one, and a user who turns it off on PREFERENCES → UPDATES has no other signal. A signed
repository is the fix, it is not built, and decision 11's last paragraph says what it would cost.
**CI publishes it, and only to the nightly channel.** Rule 3 is untouched and applies with its full force:
the token that writes a release is the capability to ship somebody a build, and it stays off runners. What
changes is nothing, because rule 9 already established a channel CI may publish to and this is a second
artefact on it. The release-shaped package is built on every `main` build and on every tag and uploaded
nowhere, exactly as the Windows and macOS packages are, which proves the `linux-x64` restore graph and the
packaging without putting an installer anybody has run in a place that invites passing it on.
Note what CI's check is and is not. `vpk` verifies that the main executable actually calls
`VelopackApp.Build().Run()` — a real assertion about the application, made for free by the packager. There
is no equivalent here, so `scripts/ci-linux-rpm.sh` makes its own: it asks the finished package what it
requires, what it provides, what files it holds and what modes they carry, and fails on each way a package
can build cleanly and then be uninstallable or unsafe. Two of those checks exist because the first realistic
payload tripped them — .NET's `libmscordbi.so` links `libmscordaccore.so`, which with bundled provides
excluded produces a dependency no host can satisfy; and `cp -a` off the wrong filesystem produced a
world-writable `/opt/DodoSSH`, which is a local privilege escalation against an application holding
plaintext credentials.
**The separations of rule 9 survive translation, and cost less here.** Two RPMs with different names owning
different paths cannot replace one another and `dnf` says so, where on Windows the same property had to be
built out of pack ids. The profile split is unchanged and still belongs to the build: `DodoChannel=nightly`
is what `ClientPaths` reads, and the packaging must not answer that question a second time.
**The device key store is settled separately and is not nothing.** The first version of this rule said this
platform had none and that the passphrase was asked for at every unlock. It has one:
[ADR 0007](0007-device-key-protection.md) decision 4 adds a TPM-sealed key released against a PIN the TPM
itself checks and counts failures against. It is weaker than Hello and Touch ID in a way that ADR names
precisely. Reaching the chip needs no group membership, because this package ships the udev rule that gives
the seated user an ACL on `/dev/tpmrm0` — a decision that ADR overturned from its own first answer, and
whose cost it states.
**What this platform does not have is a signature on the package**, which is not decision 8's "unsigned for
now": nothing on this platform
refuses or warns about an unsigned RPM, so there is no dialog to buy a certificate to remove. What stands in
for one is the same thing that stands in for it on the forge — TLS, and a project that publishes from one
address. An RPM repository would change that, because repository metadata is signed and a repository is a
thing users add once and then trust; that is the point at which a key becomes necessary rather than optional,
and it is the reason building one is a decision and not a chore.
**x86_64 only, for decision 10's reason and not a different one.** aarch64 is one argument to the publish
and one to the script. What is missing is somebody to run Phase 19 on such a machine, and this project does
not ship desktop builds nobody has run. The same sentence rules out claiming RHEL 9, which ships WebKitGTK's
4.0 API line under a soname the package deliberately does not accept: `dnf` refusing with *nothing provides
libwebkit2gtk-4.1.so.0* is a better answer than an install that draws a blank terminal.
## Consequences
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
@@ -369,6 +469,10 @@ ADR 0011 changes.
genuinely the nicest onboarding available and will be proposed again.
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
inherit it.
- **A Velopack channel for Linux.** The mechanism exists — Velopack builds an AppImage — and repeating
decisions 1 through 9 a third time would have been the consistent-looking move. Declined in decision 11
because this is the one desktop platform that already has an updater the user controls, and a second one
answering to this project is the capability decision 4 exists to make unsettable.
- **Azure Trusted Signing**, for now, on custody grounds rather than on mechanics. See decision 8.
- **A hand-edited version property.** Considered seriously against MinVer, and it loses on the thing that
matters here: the tag is already the version of record for the container image, and two places to write
+246
View File
@@ -0,0 +1,246 @@
# ADR 0015 — The Android terminal's input path leaves the WebView
- Status: proposed — **Stage 1 built, unverified on a device**
- Date: 2026-08-25
- Builds on: [`docs/android-port.md`](../android-port.md) §9, [ADR 0011](0011-android-distribution.md)
- Does not settle: whether the renderer stays xterm.js. That is [Stage 3](#stage-3--the-renderer-deliberately-unsettled), and it is deliberately left open.
## Context
The phone got the desktop's terminal, and the reasons were good ones. `TerminalDataPlane` already
served the page and a binary protocol over a loopback WebSocket, the xterm.js bundles were embedded
resources and platform-neutral, and `Avalonia.Controls.WebView` shipped a `net10.0-android36.0`
target. `docs/android-port.md` called it *"better news than expected"*, and for rendering it was.
**For typing it was not, and typing is the complaint.** Not scrollback, not colour, not throughput —
the act of putting characters into a shell. That narrows the problem usefully, because the input path
and the render path are separable, and only one of them is wrong.
### Three things sit between a finger and a byte, and the middle one is the fault
**1. Avalonia and the WebView fight over native focus.** `AvaloniaView.DispatchTouchEvent` ends every
handled touch — DOWN and UP alike — with a `RequestFocus()` for Avalonia's own view. The WebView's
input connection dies with its focus, so the keyboard swaps layouts and the inset churn can park it
over the accessory row that was tapped. `Platform/TerminalFocus.cs` exists to hand focus back, posted
to the next main-looper message so the platform has taken its turn first. It works. It is also a
counter-move in a fight that only exists because the thing holding the input connection is not the
thing the framework thinks is focused.
**2. The keyboard is a prose keyboard, and no DOM attribute makes it stop.** This is the one that was
worth checking rather than assuming, and the check inverted the obvious conclusion.
The vendored xterm.js *already* sets every hint the web platform has on its helper textarea —
`autocapitalize="off"`, `autocorrect="off"`, `spellcheck="false"`. They are set, and Gboard shows the
suggestion strip and predictive text anyway. So this is not a missing attribute; it is a ceiling.
Termux's `TerminalView.onCreateInputConnection` says what the ceiling is. It sets:
```
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
```
**`VISIBLE_PASSWORD` is the load-bearing half.** `NO_SUGGESTIONS` alone is advisory and most keyboards
ignore it — a point made in Avalonia's own Android keyboard PR discussion, not only in Termux's. What
actually turns off autocorrect, the suggestion strip and the predictive composing layer is the
*variation*: a field the keyboard believes is a visible password gets no dictionary. That is an
`EditorInfo.inputType` on a native `InputConnection`, and a page inside a WebView has no way to reach
it — Chromium computes the `EditorInfo` from the focused DOM node, and no combination of attributes on
a `<textarea>` produces the visible-password variation.
**3. The accessory row is a workaround for a keyboard, not a keyboard.** Esc, Tab, Ctrl and the arrows
are right and stay. But every tap on one costs the focus round-trip in (1), and none of them can be
held or repeated.
### The finding that decides this ADR
Avalonia already emits Termux's exact `inputType`. Decompiled from the pinned
`Avalonia.Android` 12.1.1, `AndroidInputMethod<TView>.OnCreateInputConnection`:
```csharp
if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
{
outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
}
```
`0x80080` is `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS`. The guard reads:
`ContentType` left at `Normal`, `Multiline` false, `AutoCapitalization` false, and
`TextInputOptions.ShowSuggestions` explicitly `false` — a `bool?` whose default is `null`, so it must
be set, not merely left alone. The same method also ORs `IME_FLAG_NO_EXTRACT_UI |
IME_FLAG_NO_FULLSCREEN` into `imeOptions`, which is the other thing Termux sets and for the same
reason: a terminal must never be replaced by a full-screen extract editor in landscape.
So the keyboard behaviour that makes Termux feel like a terminal is **already available on the
version this repository pins**, to any Avalonia control that asks for it. It is not available to the
WebView, and never will be. That asymmetry is the whole decision.
## Decision
### Avalonia owns the keystrokes. The WebView renders and nothing else.
A focusable Avalonia control — `TerminalInputSurface` — becomes the terminal's input target. It draws
nothing. It sits in the same `Panel` as the renderer, takes focus when the terminal surface is shown,
and keeps it for as long as that surface is up.
- `TextInputOptions.ShowSuggestions = false`, with `ContentType`, `Multiline` and `AutoCapitalization`
left at the defaults the guard above requires. This is the entire fix for the suggestion strip.
- `TextInputOptions.ReturnKeyType = Return`, which is the fix for Enter — see the correction below.
- It implements `TextInputMethodClient` with an empty surrounding text and a zero-length selection.
A terminal has no document for an IME to edit; committed text is forwarded and nothing is retained.
- Committed text and key events are encoded to bytes and written to the session through the same path
`TerminalScreen`'s accessory keys already use. No new transport, no change to `TerminalDataPlane`.
- The native WebView is made non-focusable (`Focusable = false`, `FocusableInTouchMode = false`) once,
at attach.
**`Platform/TerminalFocus.cs` is deleted by this, not amended.** The fight it referees ends when only
one side is a focus candidate. Its remarks should move into the new control as the record of why the
WebView is deliberately unfocusable, because that reasoning outlives the workaround.
`Platform/SoftKeyboard.cs` stays. Dismissing a keyboard on the way to a sheet is still a platform
call, and Avalonia still offers no way to close one.
### Not: patching xterm.js's textarea
Already tried by xterm.js itself, and already failing — see Context (2). The attributes are set. The
platform ignores them. There is no version of this that reaches `VISIBLE_PASSWORD` from inside a
WebView, so the effort buys nothing.
### Not: a native Android `View`, Termux-shaped, today
Termux's `TerminalView` + `TerminalRenderer` drawing to a `Canvas` is the right architecture for a
phone-only terminal, and DodoSSH is not a phone-only terminal. Writing one now means two terminal
implementations — this one and the desktop's — with two escape-sequence behaviours to keep honest
against each other, for a defect that is entirely in the input path. That is a large bill for the
wrong problem.
It also does not become unavailable later. Stage 3 keeps it on the table.
### Not: replacing xterm.js now
Rendering is not the complaint. xterm.js with the WebGL addon draws a correct, fast grid, and
`terminal.js` already handles renderer death, reattach and refit — hard-won behaviour recorded in
`docs/android-port.md` that a rewrite would have to re-earn. Replacing a working renderer to fix a
keyboard is the wrong trade, and doing it in the same change as the input move would make neither
half attributable when the result is judged on a device.
## Staging
### Stage 1 — the input surface · **built**
The decision above, and nothing else. This is the change that answers the complaint, and it should be
judged on a device on its own before anything else is built on it.
What landed:
| | |
| --- | --- |
| `Views/TerminalInputSurface.cs` | The 1×1 focusable control, `ShowSuggestions = false`, the IME client that retains nothing, and the key→escape-sequence table |
| `Views/TerminalInputEventArgs.cs` | One keystroke's bytes, on the way to `TerminalScreen.SendAsync` |
| `Platform/RendererFocus.cs` | Makes the `WebView` non-focusable and reports taps on it |
| `Platform/TerminalFocus.cs` | **Deleted.** The fight it refereed has one contestant now |
| `Views/TerminalScreen.axaml{,.cs}` | Hosts the surface, raises the keyboard on arrival, drops the two posted focus counter-moves from the accessory row |
**One thing gained that was not asked for, and it was a real defect:** the Ctrl latch now reaches the
software keyboard. It never did before — everything typed went straight into the page and never passed
`SendAsync`, which is where the latch lives — so `ctrl` then `c` was inert unless the second key also came
from the accessory row. Check 11.10d covers it.
**Verify before building anything on this:** checks 11.10a–11.10e in `docs/manual-checks.md`, and 11.10b
first of all, because it is the falsification test at the end of this document.
> **⚠️ Corrected on the first device run: every Enter closed the keyboard.** The premise held — the
> suggestion strip, autocorrect and predictive text were all gone — but the field had been left with
> Avalonia's default `ReturnKeyType`, and the same `OnCreateInputConnection` maps that, on a field that is
> not `Multiline`, to `IME_ACTION_DONE`:
>
> ```csharp
> _ => options.Multiline ? 1073741824 : 6, // 6 is IME_ACTION_DONE
> ```
>
> Done means done: the keyboard performs the action and dismisses itself. On a text box that is right, and
> on a terminal it fires on every command. `TextInputReturnKeyType.Return` — value 1, so
> `returnKeyType - 1 => 0 => 1073741824` — is `IME_ACTION_NONE`, which leaves Enter a plain newline with no
> action for the IME to perform and no reason for it to hide.
>
> **The trap worth recording, because it is the same trap 11.10b already warned about from the other side:**
> `Multiline = true` also reaches `IME_ACTION_NONE`, through the default arm, and is the wrong way there. It
> ORs `TYPE_TEXT_FLAG_MULTI_LINE` into `InputType`, which moves it off `1` and silently fails the
> `ShowSuggestions` guard. Taking the obvious route to a working Enter would have handed the suggestion
> strip back and looked like the whole ADR had failed. `ReturnKeyType` touches `imeOptions` only.
>
> Enter's *byte* was never in doubt and is not what changed — `IME_ACTION_DONE` was already synthesising
> `KEYCODE_ENTER` through `AvaloniaInputConnection.PerformEditorAction`, so commands ran; the keyboard just
> left afterwards. `OnTextInput` now also normalises line endings to CR, for the case where a keyboard with
> no editor action commits `"\n"` as ordinary text instead of sending a key event, and so that a pasted
> `"\r\n"` does not run its line twice.
### Stage 2 — the keys around it
Only once Stage 1 is proven, because each of these is cheap *given* Avalonia owning input and awkward
without it:
- **Hold-to-repeat** on the arrows and Backspace. Currently impossible without a focus round-trip per
tap.
- **Ctrl held rather than latched**, with latching kept as the fallback. `TerminalScreen`'s remark is
right that latching is what phone SSH clients do; held becomes possible when one control sees both
the modifier press and the following keystroke.
- **Pinch and volume keys for font size**, replacing the pinned `A−`/`A+` buttons. Both are what
JuiceSSH does and what its users name first; the buttons stay as the discoverable path.
Volume-key capture is an `onKeyDown` on the activity and is deliberately opt-in — a phone that cannot
change its volume with a shell open is a worse phone.
### Stage 3 — the renderer, deliberately unsettled
If, after Stages 1 and 2, the terminal still reads as a web page — touch selection, scrollback
survival across renderer death, scroll feel — then the renderer is the next question, and the
candidates are known:
| Option | What it is | Cost |
| --- | --- | --- |
| [XTerm.NET](https://github.com/tomlm/XTerm.NET) | MIT, headless, UI-agnostic; VT100/ANSI, dual buffers, 256/true colour, wide chars | Evaluate emulation fidelity against xterm.js |
| [VtNetCore](https://github.com/darrenstarr/VtNetCore) | MIT, .NET Standard 2.0, older but proven | Maintenance status |
| Port xterm.js's core | Exact behavioural parity with the desktop | Largest, and a permanent fork |
Rendered by a custom Avalonia `Control` via `DrawGlyphRun`, which is Termux's architecture expressed
in Avalonia and would work on the desktop too — eventually letting the WebView2 dependency go, and
with it the `targetSdk` 36 floor `Avalonia.Controls.WebView` currently forces.
**Stage 1 is not wasted if Stage 3 happens.** The input surface is exactly the layer such a control
would need, unchanged. That is the reason for splitting them.
## Consequences
**What gets better, and it is one thing done properly.** No suggestion strip, no autocorrect, no
predictive composing layer, no full-screen extract editor, and no focus flap on an accessory key.
That is the difference between typing at a shell and fighting a phone.
**CJK and other composing input is degraded, and this is a real cost.** `VISIBLE_PASSWORD` suppresses
composition, which is precisely what makes it work for a terminal and precisely what a Chinese,
Japanese or Korean user needs. Termux has the same limitation and lives with it. If it ever needs
answering, the answer is a per-session toggle that drops `ShowSuggestions = false` and accepts the
prose keyboard back — not a middle setting, because there isn't one.
**Text selection and copy are unchanged**, still xterm.js's own DOM handling, still without touch
selection handles — [xterm.js #5377](https://github.com/xtermjs/xterm.js/issues/5377) is open and
unassigned and this ADR does not fix it. Stage 3 territory.
**Scrollback still dies with the renderer process.** Unchanged, still honest about it via the replay
banner, still Stage 3 territory.
**Nothing here is verifiable headless**, for the reason `docs/manual-checks.md` §11 already gives.
Stage 1 needs new manual checks — at minimum: the suggestion strip is absent with a shell open; an
accessory key does not disturb the keyboard (§11.10a, which should now pass trivially rather than by
posted counter-move); landscape does not produce a full-screen extract editor; and a hardware
keyboard still reaches the shell.
**The hardware-keyboard path moves.** It currently arrives through the WebView; after this it arrives
through Avalonia. Behaviour should be identical and probably will not be on the first try.
## What would falsify this
If, on a device, `ShowSuggestions = false` does not suppress the suggestion strip — because the guard
was not satisfied, or the keyboard ignores the variation too — then Stage 1's premise is wrong and
the honest next step is Stage 3 with a native `InputConnection`, not more Avalonia. **Check this
first, on the smallest possible spike, before building `TerminalInputSurface` properly.** It is one
attached property on a focusable control and a look at the keyboard.
+12
View File
@@ -269,6 +269,12 @@ accessory key row for this. That is UI work, not porting.
> reconnected; earlier output stayed on the host"* — is that honesty put where the person looking at the
> terminal will actually read it, not buried in a log.
> **⚠️ Under review, on the input half only.** The renderer's story above stands; what has not held up in
> use is *typing*. A software keyboard over a WebView cannot be told to stop autocorrecting — the DOM
> attributes xterm.js already sets are advisory and Android ignores them — and the accessory row pays a
> focus round-trip per tap. [ADR 0015](adr/0015-android-terminal-input.md) proposes moving the input path to
> Avalonia and leaving the renderer exactly as this section describes it.
---
## Decisions taken
@@ -749,6 +755,12 @@ Recorded so they are choices rather than accidents. Any of them is cheap to revi
local-and-non-roaming requirement more cleanly than any desktop platform does.
- **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns
something like `localhost` on Android and would make every entry from a phone indistinguishable.
- **The Hetzner Cloud importer is not ported, and it is the one gap here that is not a platform limit.**
Its view model is in `DodoSSH.Client.Shell` like every other and its reader is an `HttpClient` call, so
both halves already run on a phone — unlike the `~/.ssh/config` importer beside it, which has no file to
read under scoped storage and never will. What is missing is a layout: the desktop draws a five-column
table of servers, and a phone version of that is a different screen rather than the same one narrower.
Said on the Preferences screen rather than left to be discovered.
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
confirmed to exist before anything is written to solve it.
+25 -1
View File
@@ -254,7 +254,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | 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 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". |
> | 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. |
@@ -741,3 +741,27 @@ to a 34-pixel rail carrying the chevron that brings it back — a rail rather th
that vanishes without trace is one people report as lost. The choice is written through to
`ClientSettings.SessionSidebarOpen` rather than held for the session: it is a decision about how much of the
window a terminal gets, and one that had to be made again on every launch would not really be on offer.
## v5c-5 — one door to settings, not three
**The user-chip popover keeps its Settings row and has lost Vaults and Preferences.** v5c gave it three, and
the table above records why: until that pass "Settings" and "Preferences" had been two doors onto the same
bare screen, so splitting them was a real fix, and Vaults arrived beside them when `VaultsScreen` became a
settings page too.
Living with it made the shape of the mistake clear. All three opened the same full-window mode and differed
only in which of its seven pages they landed on — and `SettingsNav`, which lists every one of those pages
including Vaults and Preferences, is on screen the entire time you are in there. So two of the three rows
were deep links into a place the third one opens, saving one click and costing a menu that read as though it
offered three destinations. A menu at the foot of the rail is for getting into settings at all; which page
you want is a question that has a rail of its own once you are there.
Nothing became unreachable: both pages are one click deeper, from the rail that was always going to be the
way to them. `NavRailTests` asserts the rows are gone from the markup rather than merely unwired, because a
row left behind with no handler is a menu entry that does nothing when pressed — which reads as a broken
application rather than a tidied menu.
Logout stays, and still lands on a page rather than on General: its confirmation card is drawn inline on the
Account page and nowhere else, so arming it from anywhere else would be a card raised where nobody could see
it. The vault switches and **New vault** above the divider stay too — they are what the popover is for
besides settings, and neither is a page of the settings mode.
+615 -26
View File
@@ -118,15 +118,17 @@ view models — this check is the version with a real connection on the end of i
### 1.8 Entering and leaving settings mode
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings**. Then repeat from a
fresh open, choosing **Vaults**, and again choosing **Preferences**.
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings** — which is the menu's
only way in now; the Vaults and Preferences rows beside it were folded back into it, because all three
opened this same mode and differed only in which page they landed on.
**Pass:** each of the three swaps the whole window's chrome — the titlebar becomes the 53px "Back to
application" bar, the ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the
update banner (if either was showing) are both gone. **Settings** lands on General; **Vaults** and
**Preferences** land on their own pages, each lit in SettingsNav's own list — General/Vaults/Account under
SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below both. Click every one of the
seven rows in turn: each swaps the content column without leaving settings mode or touching the titlebar.
**Pass:** it swaps the whole window's chrome — the titlebar becomes the 53px "Back to application" bar, the
ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the update banner (if either
was showing) are both gone. It lands on **General**, with SettingsNav's own list beside it —
General/Vaults/Account under SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below
both. Click every one of the seven rows in turn: each swaps the content column without leaving settings mode
or touching the titlebar, and Vaults and Preferences are among them — which is the whole reason the popover
no longer carries its own rows for those two.
Now open a terminal, leave it showing, and open Settings from the user chip again. Press **Back to
application** — or, without touching anything else, press Esc.
@@ -134,7 +136,8 @@ application** — or, without touching anything else, press Esc.
**Pass:** both return to exactly the screen that was showing before Settings was opened — the terminal,
still running, rather than HOSTS or anywhere else. Switching between settings pages first (General → Vaults
→ Preferences) does not change what "back" goes back to; only the screen open the moment Settings was
**first** entered does.
**first** entered does. The other doors into this mode — the palette, and the Preferences page's own two
importer rows — behave the same way, so the popover having one row rather than three changes nothing here.
**Failure means:** a return to HOSTS regardless of where Settings was opened from is
`MainWindowViewModel.settingsReturnScreen` being recaptured on every `EnterSettings` call rather than only on
@@ -165,7 +168,9 @@ ordinary nav rail destination does through `MainWindowViewModel.ShowScreen`. An
comes up over settings mode the same way it would over any other screen.
**Failure means:** a back button that never changes label is `SettingsTitleBar.axaml`'s two buttons both
bound to the same side of `IsImportOpen`. SettingsNav lighting a different row while the importer is up is
bound to the same side of `IsImporterOpen` — which is the flag now, rather than `IsImportOpen`, because
there are two importers over that page and the bar behaves identically for both. SettingsNav lighting a
different row while an importer is up is
`MainWindowViewModel.IsSettingsPreferencesPage` reading `IsImportOpen` when it must not —
`Import.dc.html` draws the rail unmoved on purpose; see the remark on `IsImportOpen`. Ctrl+K doing nothing
over the importer would be a guard added to `OnKeyDown` for `IsSettingsMode` that the design never asked for
@@ -197,6 +202,126 @@ That is what the "is this still the page's socket" guard in `connect()` exists t
input while the banner is *clear* is neither: the socket is open and dead, which nothing on this page can
currently see — see the note at the end of 11.12a.
### 1.10a A remote that floods the terminal does not grow the process · **watch Task Manager**
Open a shell and run `yes dodossh`. Leave it running for a full minute with the tab in front of you, then
switch to another screen and leave it another minute, then come back. Watch the application's memory in
Task Manager (Details tab, *Memory (active private working set)*) the whole time.
**Pass:** memory settles at a plateau and stays there — the number moves as the GC breathes, but there is no
staircase. Ctrl+C, and the prompt comes back straight away. Somewhere in the scrollback there may be a dim
grey line reading `── N MB of output was dropped; the terminal could not keep up ──`; that line is a pass,
not a failure. It is this client saying it discarded output it could not draw, which is what keeps the
plateau flat, and the number after it tells you how far behind the display got.
**Failure means:** a working set that climbs steadily for the whole minute and does not come back down after
Ctrl+C is the defect this check exists for, and it will not be in this repository's own buffers — it is
SSH.NET's. `ShellStream` copies everything the channel delivers into a buffer that doubles and is never
given back, and the channel keeps delivering because `Channel.OnData` re-opens the SSH receive window as
bytes *arrive* rather than as they are consumed. Measured against the container in
`DodoSSH.Client.Ssh.Tests`, a reader that stops entirely leaves that buffer holding 44 MB after five
seconds. The only thing that keeps it empty is `TerminalSessionPump` reading continuously and bounding what
it holds in `OutputBacklog` instead — so a staircase here means something has put a wait back into that read
loop. `ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog` is the test that fails first.
### 1.10b A tab whose shell exited stops costing anything · **needs a debugger or `netstat`**
Open a shell, type `exit`, and **leave the tab open**. Note the host and port. Then, from a terminal outside
the application, run `netstat -ano | findstr <port>` and look for the connection to that host.
**Pass:** the connection is gone within a second or two of the shell exiting, even though the tab is still on
screen showing the session ended and still offering RECONNECT. Clicking that reconnect still works, and
closing the tab afterwards still works.
**Failure means:** a connection that is still ESTABLISHED for as long as the tab is open is the session
holding an authenticated socket it has no further use for — with a keep-alive putting a packet on the wire
every thirty seconds and SSH.NET's read buffer still at whatever size the session's busiest moment grew it
to. It used to do exactly that, until whichever tab it was got closed. `TerminalWorkspace.ReleaseAsync` is
what gives it back now, and `ASessionWhoseRemoteHasExited_GivesBackItsConnectionWithoutWaitingForTheTab` is
the test that says so.
### 1.11 The right button copies and pastes, and never opens a menu
Open a shell and run something that prints a few lines — `ls -la /etc`. Drag a selection across part of the
output with the left button, then **right-click inside the selection**.
**Pass:** the highlight disappears at once, **no menu appears anywhere**, and the text is on the clipboard —
paste it into Notepad, or into the connect box, to be sure. The pane still takes typing straight afterwards.
Now **right-click again with nothing selected.**
**Pass:** the clipboard's text appears at the prompt as one insertion and **Enter is not pressed** — it sits
there waiting. On bash it will be highlighted in reverse video until the next keystroke, which is right: that
is bash marking a bracketed paste, and it is how you can see the markers arrived.
**And the multi-line case, which is the one worth the most care.** Copy three separate lines out of a text
editor — `cd /tmp`, `pwd`, `ls` — and right-click in the terminal.
**Pass:** all three lines land at the prompt as one thing being edited and **nothing runs** until Enter is
pressed. Three commands executing the moment you right-click is the failure this is looking for.
**On a Mac, one more:** right-click over a word with nothing selected and confirm it pastes rather than
selecting the word under the pointer. xterm.js defaults `rightClickSelectsWord` to true on macOS alone;
`terminal.js` turns it off, and this is the one platform where forgetting to would show.
**What the tests already own.** `RendererClipboardTests` runs `terminal.js` itself and proves which frame a
right-click sends, which session it is addressed to, that the selection is dropped after a copy and that the
answer comes back through `paste()` rather than `input()`. `ShellFlowTests` proves the round trip through the
host's own clipboard. What is left here is the WebView: that a real `contextmenu` event reaches a listener
registered in the capture phase, that no native menu is drawn once it is prevented, and that xterm's real
`paste()` brackets the text — none of which a Jint engine can answer.
**Failure means:**
- **A context menu appears.** The event is not being prevented, or not being seen. `handleRightClick` takes
it in the capture phase on the pane; a listener registered without the third argument to
`addEventListener` would still prevent the menu, so a menu means the listener is not attached at all.
- **A permission prompt appears** — *"…wants to see text and images copied to the clipboard"*. Somebody has
routed the read through `navigator.clipboard.readText()`, which is exactly what the `ClipboardPaste`
opcode exists to avoid. The clipboard is the host's.
- **A drag started exactly where you last right-clicked selects nothing.** xterm's own `contextmenu`
handler ran: it parks its invisible helper textarea under the pointer, 20 pixels square at z-index 1000,
to serve the native menu. The page stops the event as well as preventing it, and `stopPropagation` is the
half that was left out. Subtle enough to be worth looking for on purpose rather than waiting to notice.
- **Three commands run on a multi-line paste.** The text went through `input()` rather than `paste()`, so
bracketed paste never happened and the shell read each newline as "run this". That is the whole reason the
`Clipboard` opcode is separate from `Paste`; see `TerminalFrame.cs`.
- **Nothing happens at all, in either direction.** The head is not passing a clipboard. Both pass one — see
`ClipboardWriter` and `ClipboardReader` in each `App.axaml.cs` — and the view model is deliberately silent
when neither is there, because there is no status line on this screen to say it on.
### 1.12 Something is on screen while it starts
Close DodoSSH and start it again. Watch the first second.
**Pass:** a small panel — about 420 pixels wide, no titlebar — appears saying **DodoSSH** and *Starting…*,
and is then replaced by the main window. Measured on a debug build against a cold
profile: the panel at 668 ms, the window at 3.1 s. A release install is quicker and the shape is the same.
What must never come back is the old behaviour, which was nothing at all on screen for the whole of it.
**Pass, and worth checking separately:** the panel is **gone** once the window is up, there is never a moment
with two windows both claiming to be DodoSSH, and clicking another application while the panel is up puts
that application in front of it. The last one is deliberate — a splash that stays on top is a splash sitting
over whatever you gave up and went back to.
**And the failure state, which is the part with something to get wrong.** It needs provoking: put a
*directory* where `cache.db` should be — `%LOCALAPPDATA%\DodoSSH.Nightly\cache.db` for the nightly — and
start the application.
**Pass:** the panel stays and the line becomes *DodoSSH could not start:* followed by the reason and
*Press Escape to close.* Escape closes it. Remove the directory afterwards.
There is deliberately nothing else on the card to contradict that line — the progress bar this check used to
describe was taken off in "Take the frozen bar off the splash, and leave the line that is true", because an
indeterminate bar over a composition that had stopped was the one thing a splash must never do.
**Failure means:** a panel left saying *Starting…* is the main window having failed to show with nothing
catching it, which is the one outcome worse than the crash it replaced — `DodoSshApp.ComposeBehind` exists to
make it impossible. Two windows at once is the show-then-close order in that method having been swapped:
closing the only open window ends the process, so the real window is shown while the panel is still up.
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.
---
## Phase 2 — Known Hosts as its own page
@@ -360,6 +485,74 @@ machine. That is the ordinary case rather than an error.
---
## Phase 2 — Importing from Hetzner Cloud
The reader has its own suite over the shapes the API returns, and the end-to-end path is covered by
`ImportingFromHetzner_ShowsTheProjectFirstAndThenStoresWhatWasTicked` against a stubbed transport. What no
test in this repository does is talk to Hetzner — there is no account, no token and no network in the
ordinary suite — so everything below needs a real project.
### 2.11a A read-only token lists your project
Make a token in the Hetzner console under **Security → API tokens** with **Read** permission only. Settings
→ Preferences → **OPEN WIZARD**, paste it, press **READ THE PROJECT**.
**Pass:** every server in that project is listed, with the address, the plan and the location the Hetzner
console shows for it. The token box is **empty** afterwards — it is used for one request and dropped — and
the Hosts screen is unchanged, because reading writes nothing.
**What to look for specifically:**
- A project with more than fifty servers must list all of them. Fifty is Hetzner's page size, and a list
that stops there exactly is the paginator not being followed.
- A server with no public IPv4 should show the first address of its `/64` with a warning chip saying so.
Check that address against what the server actually answers on — the convention is Hetzner's own images,
not a fact the API states, and a machine whose operator moved it elsewhere in the `/64` will not answer.
- A server the console shows as **off** should still be listed, with `off` in the STATE column.
**Failure means:** the importer and the console disagree about what the project holds, which produces
bookmarks that dial the wrong machine. Worse than an import that refused.
### 2.11b A wrong token says so, and says where tokens come from
Paste anything that is not a valid token — a truncated one is the realistic case, since the console shows
the value once.
**Pass:** the wizard stays on the token step, the message names the Hetzner console and the Security → API
tokens page, and the box is cleared for a fresh paste. Nothing has been stored.
Now try a token from a **different** project.
**Pass:** it lists that project instead. A token belongs to one project and there is no API call that will
say which — an account with several projects runs this once per project, which the note above the box says.
### 2.11c The private-network choice does not fall back
With a project holding at least one server on a private network and one on none, press **PRIVATE NETWORK**.
**Pass:** the server with no private network shows an em dash for its address, a chip reading *no address of
that kind*, and a tickbox that cannot be ticked — including by the tick-all box in the header. The other
one shows its `10.x` address.
**Failure means:** a public address quietly stored under a choice that asked to stay off the internet. This
is the one asymmetry in how addresses resolve and it is deliberate; v4 and v6 fall back to each other,
private does not.
### 2.11d Imported hosts are correct, and ask for a password
Import a couple, then open one on the Hosts screen.
**Pass:** the address, port and login user are what the wizard showed, and the notes carry the server id,
the plan, the location and the image — and **not** the power state, which would be stale the moment somebody
pressed a button in the console. Connecting asks for a **password**: no key came with these hosts, because
Hetzner has never held the private half of one. Bind it to a key in your keychain and it connects.
Now go back to the list and import the same project again.
**Pass:** the ones already stored are marked *already here* and arrive unticked. Importing one twice is
allowed and takes a click.
---
## Phase 2 — Drag and drop on the SFTP page
**This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native
@@ -1687,6 +1880,27 @@ bottom and no scroll position left part way down.
**Failure means:** the inset is being applied but not cleared — the closed state is not being read from the
event, or the margin is only ever added to.
### 10.4a Nothing is stranded when the keyboard is still up on the way *out* · **the one 10.4 missed**
Focus any box, and with the keyboard still on screen leave the application — Home, or the task switcher,
or anything that puts another app in front. Come back to it.
**Pass:** the interface fills the screen. Do it from the terminal too, where the keypad and the shell should
both be where they were.
**Failure means:** `PhoneShell` is not re-reading the inset on the way back in. This is a separate path from
10.4 and it is the one that has actually shipped broken: Android takes the keyboard away when the activity
stops and Avalonia's input pane raises no `StateChanged` for it, because the pane goes with the window
rather than closing in front of one. So the last value applied was for a keyboard that was up, and the
application returns holding a whole keyboard's height of empty canvas — measured at 838 of 2340 pixels on a
Pixel 5, on every screen, and staying that way until something opens and closes the keyboard. See
`PhoneShell.OnActivityResumed` and `PhoneEnvironment.Resumed`.
Worth doing in both directions: a resume that *should* keep the inset is the other half. Trigger a
permission prompt or the biometric sheet with the keyboard up, dismiss it, and check the box being typed
into is still clear of a keyboard that is genuinely still there — a fix that flattened the inset on every
resume would pass the paragraph above and fail this one.
### 10.5 Rotating with the keyboard up
Focus a passphrase box, then turn the phone sideways.
@@ -1840,12 +2054,22 @@ Now tap **Tab** on the accessory row, or **A+**, and type again.
keyboard.
**Failure means:** the buttons on that row have become focusable again. An ordinary Avalonia button takes
focus on tap, which takes it off the `NativeWebView` — and the package's own `OnLostFocus` then resigns the
page's focus. The row goes on working, because its keys are pressed rather than typed into, so the symptom
is a terminal that answers the buttons and ignores the keyboard: it reads as the session having died. See
`Focusable = false` in `TerminalScreen.axaml.cs` and the focus entry in `docs/platform-flags.md`.
focus on tap, which takes it off `TerminalInputSurface`. The row goes on working, because its keys are
pressed rather than typed into, so the symptom is a terminal that answers the buttons and ignores the
keyboard: it reads as the session having died. See `Focusable = false` in `TerminalScreen.axaml.cs`.
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
**Also worth typing while a hardware keyboard is attached**, because that path moved under
[ADR 0015](adr/0015-android-terminal-input.md) and is the one most likely to have been broken by the move:
it used to arrive through the `WebView`'s own key handling and now arrives through Avalonia's. Confirm that
Ctrl-C interrupts **and leaves no stray `c` at the prompt** — Avalonia raises the letter's own text event
whether or not the key event was handled, so a doubled character here means `controlByteAlreadySent` is not
doing its job — that the arrow keys reach shell history, that Backspace deletes one character, and that Tab
completes a path. **Forward-delete is a known, deliberate loss** — the Delete key sends a backspace,
because Avalonia turns an IME's `deleteSurroundingText` into a `KEYCODE_FORWARD_DEL` and a broken backspace
on every phone is worse than a missing forward-delete on the few devices with the key. See
`TerminalInputSurface.TryTranslate`.
Worth doing on the software keyboard too, where the old fault showed as the keyboard closing on the first
tap of an arrow key.
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
@@ -1853,18 +2077,98 @@ tap of an arrow key.
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
keep typing on the software keyboard.
**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
**Pass:** the keyboard settles back unchanged — same layout, same height — and everything typed after the
tap still reaches the terminal. The accessory row stays visible above the keyboard throughout. There should
be **no blink at all** now: under [ADR 0015](adr/0015-android-terminal-input.md) the input target is an
Avalonia control, so the focus the platform hands to Avalonia's own view after every dispatched touch is
already the focus the keyboard wants, and there is nothing to hand back.
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
**Failure means:** the buttons on that row have become focusable again, taking Avalonia's focus off
`TerminalInputSurface`. The old failure — a keyboard that swapped layouts and parked itself over the row —
belonged to the arrangement where the `WebView` held the input connection and is not reachable from here;
if it reappears, the renderer has taken focus back and `RendererFocus.Suppress` did not run. See check
11.10c.
### 11.10b The keyboard over a terminal is a terminal's keyboard · **the important one for ADR 0015**
Open a shell and look at the keyboard, without typing anything yet. Then type a few words of ordinary
English — `ls`, `cd etc`, `sudo apt update`.
**Pass:** there is **no suggestion strip** above the keys, nothing is autocapitalised at the start of a line,
no word is autocorrected, and no predictive text appears ahead of the cursor. Backspace deletes exactly one
character per press. Rotating to landscape does not replace the terminal with a full-screen text editor.
**Failure means:** the premise of ADR 0015 is wrong and everything staged on it needs rethinking, so find
out *which* half failed before concluding anything. Either `TextInputOptions.ShowSuggestions` is not
reaching Avalonia's `AndroidInputMethod` — the guard there is narrow and needs `InputType` to be exactly
`TYPE_CLASS_TEXT`, so anything that sets `ContentType`, `Multiline` or `AutoCapitalization` on
`TerminalInputSurface` or an ancestor silently disables it, `inherits: true` being the trap — or the
keyboard is ignoring `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD`, which would mean no Avalonia-side fix exists
and the honest next step is a native input connection. Worth repeating on a second keyboard (Gboard and one
other) before deciding, because this is a claim about keyboards rather than about Android.
### 11.10c The renderer cannot take the keyboard, and a tap still brings it back
With a shell open, put the keyboard away with the system back gesture. Then tap the terminal. Separately:
with the keyboard up, drag the terminal to scroll its scrollback.
**Pass:** the tap brings the keyboard straight back. The drag scrolls the scrollback and does **not** raise
the keyboard. At no point does a text caret or a selection handle appear inside the terminal.
**Failure means:** if the tap does nothing, `RendererFocus.Suppress` never found the `WebView` — it is
called from two places, the posted `EnvironmentRequested` handler and the `IsTerminalShowing` transition,
and a miss in both leaves no tap listener. If the drag raises the keyboard, the touch-slop test is not
working. If a caret appears, the `WebView` is still focusable and the page has taken the input connection
back, which is the whole failure ADR 0015 exists to prevent.
### 11.10d The Ctrl latch reaches the software keyboard
With a shell open and something running that can be interrupted — `sleep 60`, or `top` — tap **ctrl** on the
accessory row and then press **c** on the software keyboard.
**Pass:** the process is interrupted, and the ctrl key returns to its unlatched colour.
**Failure means:** typed characters are not going through `TerminalScreen.SendAsync`. Note that this check
could not pass *before* ADR 0015 and is not a regression test but a new capability: the latch used to apply
only to the accessory row's own keys, because everything typed went straight into the page and never passed
the code that holds the latch.
### 11.10e Enter runs the command and the keyboard stays where it is
With a shell open and the software keyboard up, type `pwd` and press Enter. Then type another command.
**Pass:** the command runs and **the keyboard is still up**, unmoved, ready for the next one. The Enter key
reads as a return arrow rather than as `DONE` or a tick. Ten commands in a row cost ten Enters and no taps
to bring the keyboard back.
**Failure means:** `TextInputOptions.ReturnKeyType` is not `Return` on `TerminalInputSurface`, so Avalonia
has mapped the field's default to `IME_ACTION_DONE` and the keyboard is doing exactly what Done means. This
was the first defect the first device run found. **Do not fix it with `Multiline = true`** — that reaches
the same `IME_ACTION_NONE` by a route that ORs `TYPE_TEXT_FLAG_MULTI_LINE` into `InputType`, which fails the
`ShowSuggestions` guard and hands the suggestion strip back; check 11.10b would then start failing and the
cause would look unrelated. See the correction in [ADR 0015](adr/0015-android-terminal-input.md).
Worth pressing Enter on an empty prompt a few times too: each one should give a fresh prompt and nothing
else. A doubled prompt per press means a line ending is arriving as both CR and LF.
### 11.10f Every key on the keypad is there, and pressing one presses it
With a shell open, look at the two rows above the keyboard without touching anything: **esc tab ctrl | - ~ /**
on the upper row and **← ↓ ↑ →** with **A−** and **A+** on the lower one. All thirteen, at once, on a 360dp
phone in portrait.
Then press them with a thumb rather than a fingertip, at an angle, twenty or so times across both rows —
including the keys at the far left and far right ends.
**Pass:** every press registers, the key darkens under the thumb while it is down, and **nothing slides
sideways**. The row is exactly where it was after twenty presses as it was before the first.
**Failure means:** a key that misses is a target that has got smaller — `Button.hotkey` is 40 tall for a
reason and Android asks for 48. A row that *moves* is the one that matters: it means something has put a
`ScrollViewer` back around the keys. That is how this row shipped originally and it does not survive contact
with a thumb — a pan and a press begin with the same few pixels of travel, the scroller claims them, and the
press is never delivered. If the set of keys outgrows the width, add a third row; do not make it scroll.
See the keypad's own remark in `TerminalScreen.axaml`.
### 11.11 Closing a connection and opening a new one both take you somewhere real
@@ -1922,6 +2226,26 @@ without either end noticing, which a suspended renderer can leave behind — sho
connected and swallows what is typed. If that is ever seen, it is a different bug from this one and needs a
liveness probe rather than a faster retry.
### 11.13 The right button on a phone with a pointer · **needs a mouse, a trackpad or a dock**
The same page as 1.11 runs on this head, so the gesture is here too — but only for a device that has a
secondary click at all. Attach a mouse or use a keyboard case's trackpad, or dock the phone, open a shell,
select some output and right-click it; then right-click again with nothing selected.
**Pass:** exactly as 1.11 — the selection drops with no menu, and the second click pastes.
**Unverified, and that is why this check exists.** Nothing here has been run against Android's WebView with
a pointer attached, so whether it delivers a secondary click as a DOM `contextmenu` event at all is an open
question. `ClipboardReader` is wired on this head regardless, because the alternative is a head where the
paste half silently does nothing and nobody finds out.
**Failure means:** if the copy works and the paste does not, the clipboard read is being refused — Android
returns nothing from the clipboard to an app that is not the foreground one, and a WebView taking the
secondary click may be leaving Avalonia's window in a state where `TopLevel` has none. If neither works,
Android is not delivering the click as a `contextmenu` event, and the gesture needs a native listener on the
`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.
---
## Phase 12 — Shared vaults: the operations that span two accounts
@@ -2565,6 +2889,32 @@ having been broken, and the fallback is to move the offer into the titlebar inst
the swap having been blocked, usually by a process still holding a file under `current\`. Being asked to
enrol again means the profile directory did not survive, which is 16.1's failure arriving late.
### 16.7a Two releases published while it is running cost one update · **needs two releases**
The nightly channel is the easier place to do this — every push to `main` publishes one — but it works the
same way on the release channel with two tags. With the client running and the banner already up for one
build, publish a second. Then press **RESTART NOW** on the banner.
**Pass:** one download and one restart, landing on the **second** build. The banner says
*"A newer build appeared. Fetching it before restarting"* with a percentage while it does, and it stays on
screen throughout rather than vanishing. Starting up afterwards offers nothing, because there is nothing
left to offer.
**And the timer's half, if you have six hours or a debugger.** Leave the client running past a check
interval with a build already fetched and a newer one published. The banner's version changes on its own,
and LATER having been pressed does not stop it coming back — a different version is a different question.
**Failure means:** two downloads and two restarts is the regression this check exists for, and it was the
shipped behaviour: the background pass skipped itself entirely once anything was fetched, so a running
client was pinned to whichever build it first saw and installed that, then found the next one at the launch
straight afterwards. On a rolling channel — where the release is deleted and remade on every push, so the
feed holds exactly one build — that was the normal case rather than a corner.
A banner that disappears when RESTART NOW is pressed is `IsRestarting` not reaching it; everything the strip
draws keys off `IsReady`, and the last look moves the state off that. A restart that does not happen at all
when the forge is unreachable is the last look having become a gate: it is a courtesy, and the build already
on disk is still newer than the one running.
### 16.8 The first connect after an update is not a cold start
Immediately after 16.7, connect to a host.
@@ -2610,6 +2960,43 @@ offer a nightly, ever, however many have been published since. It reads a differ
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
rather than as a bug in the nightly.
### 16.11 One instance, and what a second launch does instead
With DodoSSH running, start it again — Start menu, desktop shortcut, or the executable directly. Do it three
times: with the window in front, with it minimised, and with it behind another application.
**Pass:** no second window appears, and the one already open comes forward, un-minimised if it was
minimised. **A flashing taskbar button rather than a raised window is also a pass**: Windows refuses the
foreground to a process that did not receive the input, which is its own rule about focus stealing and not
something to defeat. Nothing happening at all is the failure.
**And the side-by-side case, which is the whole reason the lock lives where it does.** 16.10's arrangement,
both builds installed, both started.
**Pass:** both run. They are separate applications with separate profiles, and the lock is a file inside the
profile — so neither can see the other's. Two people signed in to one machine is the same property from the
other direction and needs no check of its own.
**And the update relaunch · needs a release.** Take an update (16.7) and press RESTART NOW.
**Pass:** the application comes back. This is the race worth naming: the updater starts the new build as the
old one is exiting, and a lock held a moment too long would make the new process refuse itself and disappear
— an update that looked like an uninstall. `SingleInstance.Claim` keeps trying for three seconds while the
lock is held by something that does not answer its pipe, which is what covers it.
**Failure means:** a second window means the lock is not being taken. A profile directory that cannot be
written to answers "you are the only one" on purpose — refusing to start an SSH client over a permissions
problem would be the wrong trade — so check that the profile exists, is writable, and has an
`instance.lock` in it while the application runs. A second launch that exits without raising the first means
the lock worked and the doorbell did not: the pipe name is derived from the profile path, so two processes
disagreeing about it means they disagree about which profile they are in. Two windows *after an update* is
the opposite failure — the outgoing process never released the lock, which means something is holding it
open past `Shutdown`.
`SingleInstanceTests` covers everything reachable without a second process: the lock, its release, the
doorbell answering more than once, and two profiles not excluding each other. What is left for a person is a
second *executable* and a real window coming forward.
---
## Phase 17 — Installing and updating the phone
@@ -2841,3 +3228,205 @@ Register a device, sync something, then remove the application.
**Failure means:** an empty directory is the pack id having been changed to `DodoSSH`, which puts Velopack's
install root on top of `ClientPaths.DataDirectory` and makes an uninstall delete a user's un-synced work.
This is the single reason the bundle is named `DodoSSH.Desktop.app`.
## Phase 19 — Installing the Linux client, and the things dnf does not check
The Linux counterpart of phases 16 and 18. One of their questions is off the table — there is no signing,
because nothing on this platform refuses an unsigned package, which is a distinct statement from "unsigned
is fine"; `build/linux/dodossh.spec` says what stands in for a signature, which is that the package comes
from the project's own forge over TLS and from nowhere else.
Everything else is here, and three things are here that neither other phase has. **The update does not
install itself**, so 19.8 is about watching a polkit prompt appear and saying no to it, where phases 16 and
18 watch an application replace itself. **The device key's gate is a PIN rather than a gesture**, with the
TPM's own lockout behind it — so 19.7 deliberately breaks something and then tells you how to put it back.
And **this package changes a permission on the machine**: 19.4 and 19.5 are about the udev rule that gives
the seated user the TPM, checking both that it works and that masking it undoes it completely.
What this phase is for is the set nobody else can answer. **CI builds this package and installs nothing**,
and every check in `scripts/ci-linux-rpm.sh` is a check on what the package *says* — its dependencies, its
file list, its modes. The code does better: `LinuxDeviceKeyStoreTests` runs the whole seal, unseal,
wrong-PIN and lockout path against a software TPM, and `LinuxUpdateChannelTests` covers the version
comparison and the digest. What none of them touch is `/dev/tpmrm0`, PackageKit, polkit and a real desktop
session, and the failures below are all of the shape "installs perfectly and then does not work".
Needs a Fedora 44 machine or newer with a graphical session. Build the package first:
```bash
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
-p:RestoreLockedMode=false --output /tmp/linux-x64
scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
sudo dnf install /tmp/rpm/dodossh-1.2.3-1.x86_64.rpm
```
### 19.1 It launches from the menu, and not only from a shell · **do this one first**
Log out and back in, or wait for the desktop to notice the new `.desktop` file, then start DodoSSH from the
applications menu rather than from a terminal.
**Pass:** the window opens, and the running application's icon in the dock or task list is the same entry
that launched it — one icon, not two.
**Failure means:** a second unlabelled icon appearing beside the launcher is `StartupWMClass` not matching
what Avalonia sets `WM_CLASS` to. The spec sets it to `DodoSSH`, which is the assembly name and is the same
for both channels; if Avalonia's answer has changed, `xprop WM_CLASS` on the window is what to compare
against. Nothing at all in the menu is the desktop database not having been rebuilt — `%post` runs
`update-desktop-database` and swallows its failure on purpose, so try it by hand before concluding anything.
### 19.2 The terminal draws, which is the WebKitGTK question
Connect to a host and use the shell: type, run something that scrolls, resize the window.
**Pass:** the terminal attaches within a second or two and behaves as it does on Windows.
**Failure means:** **a blank pane where everything else works is the known one**, and it is the reason
`MainWindow.OnTerminalEnvironmentRequested` exists — the GTK WebView adapter in its default mode can only
open a window of its own, so a control asked to host it in place has nothing to draw. If that method has
stopped setting `ExperimentalOffscreen`, or the vendor has withdrawn the flag, this is what it looks like.
A pane that reports a renderer timeout instead is the loopback WebSocket rather than the backend.
### 19.3 The icon is the mark, at the sizes the desktop actually asks for
Look at it in the applications menu, in the dock, and in the window switcher.
**Pass:** the accent tile and the `>_` mark, filling its square, legible at the small end.
**Failure means:** a generic icon is `Icon=` in the desktop entry not matching an installed file name — both
are the package's binary name, so they can only disagree if one was edited. A blurry small icon is the
48-pixel file missing and the 256 being scaled down; `dodossh-icon.ps1` draws four sizes and the spec
installs four.
### 19.4 The udev rule gives *this* session the TPM, and gives nobody else it · **needs a real TPM**
Installing the package should be the whole setup — no group, no administrator, no fresh login. Check it
from a graphical session on the machine itself:
```bash
getfacl /dev/tpmrm0 | grep "^user:"
id -nG | tr ' ' '
' | grep -x tss # expect nothing: you are not in the group
```
**Pass:** `getfacl` shows a `user:<you>:rw-` entry that is **not** there for any other account, and you are
not in `tss`. Now `ssh` into the same machine from elsewhere and run the same `getfacl`: the entry for you
is still the *local* session's, and a shell that is not on the seat cannot open the device — `head -c1
/dev/tpmrm0` answers "Permission denied".
**Failure means:** no ACL at all is the rule not having been applied. `udevadm test --action=change
/sys/class/tpmrm/tpmrm0 | grep uaccess` says whether the tag is being set, and the load order matters —
71 has to fall between `60-tpm-udev.rules` and `73-seat-late.rules`, which is what turns the tag into an
ACL. If the file is present and untagged, check nothing in `/etc/udev/rules.d` is masking it. **An ACL
granted to a remote session would be the serious one**, and would mean the tag had been put on `tpm` rather
than `tpmrm` or the mode widened instead — neither of which this rule does.
### 19.5 Masking the rule puts the machine back the way it was
```bash
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=tpmrm --action=change
```
Log out and back in, then open the security settings page.
**Pass:** registering a device key is **not** offered, and unlocking asks for the passphrase every time —
the same behaviour as a machine with no TPM. `udevadm test` reports the file as "masked by previous entry".
**Failure means:** still being offered it means `LinuxDeviceKeyStore.IsSupported` answered from the presence
of the device node rather than from opening it. Accepting that offer would put a wrap on the server that
nothing can ever open, and the failure would arrive at the next unlock rather than now.
**Then undo it** — `sudo rm /etc/udev/rules.d/71-dodossh-tpm.rules` and reload — before 19.6.
### 19.6 The PIN unlocks, and the TPM is what checks it
Register a device key from the security settings page and choose a PIN. Lock the vault and unlock it again.
**Pass:** registering asks for a PIN twice and shows no other prompt. Unlocking asks for it once and opens
the vault. Cancelling that prompt lands on the passphrase screen, which works.
**Failure means:** a PIN prompt that opens the vault whatever you type would mean the unseal is not reaching
the TPM at all, which is the one failure that would make this whole feature decorative.
### 19.7 A wrong PIN gets nothing, and the TPM stops answering
With a device key registered, lock the vault and enter a wrong PIN. Do it several times.
**Pass:** each attempt lands on the passphrase screen. After a handful, even the **correct** PIN lands
there too — the TPM has entered lockout and is refusing everyone, which is the point.
**Failure means:** wrong PINs that never stop being accepted for the attempt is the `NoDA` attribute having
been set on the sealed object, which would make a four-digit PIN guessable at full speed by anything holding
the blob and the machine. `LinuxDeviceKeyStoreTests` asserts this against a software TPM; this is the same
assertion against real firmware, where the lockout is real and recovery takes hours.
**Then put it back:** `sudo tpm2_dictionarylockout --clear-lockout`, or wait out the recovery time. Note
which, because a user who trips this will ask — and the honest answer is that it needs an administrator.
### 19.8 An update is noticed, fetched, and installed by PackageKit and not by DodoSSH
With the nightly installed, wait for a newer one to be published — or press **CHECK NOW** on
PREFERENCES → UPDATES once there is one.
**Pass:** the banner names the new version and downloads it. Pressing install raises the desktop's own
*"Authentication is required to install software"* prompt, drawn by polkit and not by DodoSSH. Accepting it
installs the package; declining it changes nothing and leaves the vault unlocked and every shell open.
DodoSSH does **not** restart, and the version on the preferences screen does not change until you close and
reopen it.
**Failure means:** an update that is offered and then refused by PackageKit is the version comparison
disagreeing with rpm — `rpm -q --queryformat '%{VERSION}-%{RELEASE}' dodossh-nightly` against the
`rpmVersion` in the release's `linux-nightly.json` is the comparison being made, and `RpmVersionTests`
covers the algorithm. A message naming PackageKit means it is not installed; it is a weak dependency, so a
minimal install legitimately lacks it. **The vault locking or shells closing when you decline is the
serious one** — it means `ApplyingEndsTheProcess` has been set true, which punishes somebody for saying no.
### 19.9 A tampered package is refused before PackageKit ever sees it
Not reachable without editing the feed, so this is the one to reason about rather than perform unless you
are changing that code: the manifest's `sha256` is checked after the download and the file is deleted if it
does not match.
**Pass:** `~/.local/share/dodossh.nightly/updates/` holds a package only when its digest matched.
**Failure means:** a leftover `.rpm` there after a failed update is a file somebody could reasonably install
by hand, named as though the application had endorsed it.
### 19.10 The nightly installs beside the release rather than over it
With the release package installed, install the nightly one too.
**Pass:** `dnf` installs it as a second package rather than as an upgrade. Both entries appear in the menu,
named *DodoSSH* and *DodoSSH Nightly*. Both run at once. Signing into the nightly asks for the passphrase
again and appears on the account as a separate device.
**Failure means:** `dnf` offering to *upgrade* rather than install is the two packages sharing a name. A
nightly that opens the release's vault already unlocked is the profile separation gone — that comes from
`DodoChannel=nightly` reaching `ClientPaths`, not from the packaging, so check
`~/.local/share/dodossh.nightly` exists rather than looking at the spec.
### 19.11 Nothing in the installed tree is writable by anyone but root
```bash
find /opt/DodoSSH ! -user root -o -perm -o+w
```
**Pass:** no output.
**Failure means:** any line here is a local account being able to replace the SSH client every other account
on the machine runs, which for an application holding a team's credentials in plaintext is the whole trust
model walked around rather than attacked. `ci-linux-rpm.sh` refuses to produce such a package and the spec
sets every mode absolutely, so a hit here means one of those two was changed — it cannot arrive by accident
from a publish directory.
### 19.12 Removing it leaves the vault, and leaves the other channel alone
Register a device, sync something, then `sudo dnf remove dodossh`.
**Pass:** `~/.local/share/dodossh` still holds the cache and the outbox. `/opt/DodoSSH` is gone entirely.
`/opt/DodoSSH.Nightly` and its menu entry are untouched.
**Failure means:** a missing cache is `ClientPaths` having been pointed somewhere under `/opt`, which would
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
something wrote into the install directory at runtime and would survive a reinstall.
+71 -10
View File
@@ -1,26 +1,30 @@
# 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
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
half does not exist, and both heads offer a checkbox that promises it.
- The **relay** pipes raw TCP through the deployment. Both halves are built: the server issues a ticket and
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`
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
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.
> **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 |
> | --- | --- | --- |
> | 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 |
> | 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 |
> | 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 |
> | 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 | **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
@@ -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
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
@@ -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.
- `Client.Import/ImportedHost.cs` — the note written onto every imported host with a `ProxyJump`, and the
remark above it.
- `README.md` and `docs/android-port.md` wherever the relay is described as available.
- ADR 0004 gains a note that its last consequence was built, and how.
- `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. ✅ at step 3.
## What the first draft of this document got wrong
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env bash
#
# Turns a published linux-x64 tree into an RPM, and then checks the RPM rather than the exit code.
#
# Called from two jobs in .github/workflows/ci.yml and written once for the reason the four copies of
# "ensure node and git" in that file are not: those cannot be factored out — a composite action would
# need the checkout it exists to unblock — and this can. The build job packages the release-shaped
# RPM and throws it away, exactly as it does for Windows and macOS; the desktop job packages the
# nightly-shaped one and publishes it. Everything that differs between those two is an argument.
#
# It is also runnable at a desk, which is the point of it being a script:
#
# dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
# --configuration Release --runtime linux-x64 --self-contained true \
# -p:RestoreLockedMode=false --output /tmp/linux-x64
# scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
#
# Nothing here holds a secret and nothing here uploads anything. Publishing is the calling job's, and
# ADR 0013 rule 3 is what decides which channel may do it.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
payload=""
version=""
channel="release"
out=""
while [ $# -gt 0 ]; do
case "$1" in
--payload) payload="$2"; shift 2 ;;
--version) version="$2"; shift 2 ;;
--channel) channel="$2"; shift 2 ;;
--out) out="$2"; shift 2 ;;
*) echo "Unknown argument: $1" >&2; exit 2 ;;
esac
done
for required in payload version out; do
if [ -z "${!required}" ]; then
echo "--$required is required." >&2
exit 2
fi
done
case "$channel" in
release|nightly) ;;
*) echo "--channel must be release or nightly, not '$channel'." >&2; exit 2 ;;
esac
# The apphost, named rather than globbed. A publish that produced no apphost at all would otherwise
# package happily into an RPM whose /usr/bin symlink points at nothing, and the first thing anybody
# would learn about it is a shell saying "No such file or directory" for a file that is right there.
if [ ! -s "$payload/DodoSSH" ]; then
echo "There is no apphost at $payload/DodoSSH, so there is nothing to package." >&2
ls -la "$payload" >&2 || true
exit 1
fi
# ◆ THE FOUR THINGS THAT DIFFER BETWEEN THE CHANNELS, AND WHY IT IS FOUR AND NOT ONE.
#
# This is ADR 0013 rule 9's table, in the spelling this platform uses. The package name keeps dnf from
# treating one as an upgrade of the other; the directory under /opt keeps the two trees apart on disk;
# the /usr/bin name and the desktop entry keep them apart in front of a person, who would otherwise
# have two identical launchers and no way to tell which was which.
#
# The fifth separation — the profile directory holding the vault cache, the outbox and the device key
# — is not here and must not be, because it belongs to the build rather than to the package: the
# nightly is published with DodoChannel=nightly, and ClientPaths reads that to suffix the directory.
# Doing it here as well would be a second answer to a question that already has one.
if [ "$channel" = nightly ]; then
name=dodossh-nightly
dir=DodoSSH.Nightly
bin=dodossh-nightly
title="DodoSSH Nightly"
else
name=dodossh
dir=DodoSSH
bin=dodossh
title="DodoSSH"
fi
# ◆ SEMVER IS NOT AN RPM VERSION, AND THE TRANSLATION IS THE ONE PLACE THIS CAN GO QUIETLY WRONG.
#
# rpm forbids `-` in a version, which is exactly the character SemVer uses to introduce a prerelease.
# MinVer's answer on main is 0.0.1-alpha.0.143, so the naive move — strip the suffix — would give
# every nightly the version 0.0.1 and make each one look like a reinstall of the last.
#
# `~` is rpm's own answer and has been since 4.10: it sorts *before* the empty string, so
# 0.0.1~alpha.0.143 is older than 0.0.1 the way alpha.143 is older than the release. Heights keep
# rising within a floored version and the first real tag moves past all of them, which is the same
# ordering property the Velopack channels have and is worth having on both.
#
# Build metadata after `+` is dropped rather than translated. It is the one part of a SemVer string
# that is defined as not participating in ordering, so keeping it would put a character rpm has no
# rule for into the field ordering is done on.
rpm_version="${version%%+*}"
rpm_version="${rpm_version/-/\~}"
case "$rpm_version" in
*-*)
echo "The version $version translates to $rpm_version, which still contains a hyphen." >&2
echo "rpm cannot order a version with one in it; this needs a rule rather than a retry." >&2
exit 1
;;
esac
# Always 1. The release field distinguishes several packagings of one upstream version, and there is
# only ever one packaging here — the version already carries the build height, because upstream and
# packager are the same repository.
rpm_release=1
echo "==> $name $rpm_version-$rpm_release.x86_64, from $payload"
# ◆ rpmbuild ON A MUSL HOST, WHICH IS FINE, AND WHY IT IS WORTH SAYING SO.
#
# The runner is Alpine. Nothing about that shows up in the package: rpmbuild composes an archive and
# some metadata, and the one part of it that reads the payload — elfdeps, which the spec leaves on for
# requires — parses ELF headers with libelf rather than loading anything, so it reads x86_64 glibc
# binaries on a musl host correctly. Building in a Fedora container was the alternative and would
# have meant `docker cp`-ing a 130 MB publish tree in and the package back out, because this runner is
# itself a container and a bind mount resolves on the daemon's host rather than here — see the note on
# the android job for the hour that cost.
if ! command -v rpmbuild >/dev/null 2>&1; then
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
echo "==> installing rpm"
if command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache rpm
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y rpm-build
elif command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends rpm
else
echo "No apk, dnf or apt-get here, so rpmbuild cannot be installed from inside the job." >&2
echo "Add rpm to the runner's image; the Linux package cannot be built without it." >&2
exit 1
fi
fi
rpmbuild --version
# ◆ THE LIST OF PRIVATE SONAMES, WORKED OUT FROM THE TREE RATHER THAN WRITTEN DOWN.
#
# build/linux/dodossh.spec turns rpm's automatic provides off for everything under /opt, because the
# libraries there are this application's private copies and have no business satisfying anybody
# else's dependency. The requires side has to be told the same thing about the same set, or a bundled
# library that links another bundled library asks for a soname nothing provides and the package
# becomes uninstallable on every host — which .NET's own libmscordbi.so, linking libmscordaccore.so,
# does on every publish.
#
# Derived here because the set belongs to the payload. A list in the spec would be Microsoft's and
# SkiaSharp's internal file names, maintained by hand, going stale on the first runtime bump — and
# going stale in the direction that produces a package nobody can install.
#
# Only the top level is scanned, which is where a .NET publish puts its native libraries. The dots
# are escaped because this becomes a regular expression; nothing else in a soname is a metacharacter.
#
# A shell glob rather than `find -printf`, which is GNU find's and not busybox's — the runner is
# Alpine, where that flag is a usage message and an empty list, which would silently disable the whole
# exclusion and put the uninstallable package back.
bundled="$(
for library in "$payload"/lib*.so*; do
[ -f "$library" ] && basename "$library"
done |
sed 's/\./\\./g' |
sort -u |
paste -sd '|' -
)"
if [ -z "$bundled" ]; then
echo "There are no native libraries at the top of $payload." >&2
echo "A self-contained .NET publish always has some, so this is a publish that went wrong" >&2
echo "rather than a package with nothing to exclude." >&2
exit 1
fi
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$out"
# --target rather than trusting the host's arch, because the host's arch here is whatever the runner
# happens to be and the payload's is decided by the RID the publish asked for. They agree today and
# an aarch64 runner would silently make them disagree.
#
# `dist` is emptied deliberately. It is the %{?dist} tag that puts .fc42 in a filename, and it names
# the distribution the package was *built* on — which for a self-contained tree built on Alpine would
# be both meaningless and, if rpm guessed something plausible, actively misleading.
rpmbuild -bb "$repo_root/build/linux/dodossh.spec" \
--target x86_64 \
--define "_topdir $work" \
--define "_rpmdir $out" \
--define "dist %{nil}" \
--define "dodo_name $name" \
--define "dodo_version $rpm_version" \
--define "dodo_release $rpm_release" \
--define "dodo_dir $dir" \
--define "dodo_bin $bin" \
--define "dodo_title $title" \
--define "dodo_payload $payload" \
--define "dodo_bundled $bundled" \
--define "dodo_icons $repo_root/src/DodoSSH.Client.App/Assets" \
--define "dodo_udev $repo_root/build/linux/71-dodossh-tpm.rules" \
--define "dodo_licence $repo_root/LICENSE"
# rpmbuild writes into <_rpmdir>/<arch>/, and every caller wants the file rather than the tree.
rpm_file="$out/x86_64/$name-$rpm_version-$rpm_release.x86_64.rpm"
if [ ! -s "$rpm_file" ]; then
echo "rpmbuild reported success and there is no package at $rpm_file." >&2
find "$out" >&2 || true
exit 1
fi
mv "$rpm_file" "$out/"
rmdir "$out/x86_64" 2>/dev/null || true
rpm_file="$out/$(basename "$rpm_file")"
echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
# ◆ AND NOW THE PART THAT IS NOT PACKAGING: ASKING THE PACKAGE WHAT IT SAYS ABOUT ITSELF.
#
# Everything above can succeed and still produce something nobody can install, and none of the ways
# that happens are visible in an exit code. Each check below is one of them.
#
# ◆ EVERY READER IN A PIPELINE HERE MUST CONSUME ALL OF ITS INPUT, AND THAT IS NOT A STYLE RULE.
#
# `set -o pipefail` at the top of this file means a pipeline's status is the rightmost non-zero one.
# A reader that stops early — `grep -q`, `head -n` — closes the pipe while the writer is still
# writing, the writer dies of SIGPIPE with status 141, and pipefail hands that 141 to the `if`. So a
# check that found what it was looking for reports the opposite, and a diagnostic printed on the way
# to `exit 1` kills the script under `set -e` before it gets there.
#
# It is a threshold rather than a constant, which is what made it look like somebody else's bug: the
# writer only blocks once it has more than a pipe buffer left to write, about 64 KB on Linux. `$files`
# is one line per packaged file, so this passed for as long as the published tree was small and then
# failed on the commit that pushed the path text past 64 KB — reporting `/opt/DodoSSH/DodoSSH` missing
# from a package that plainly contained it, and exiting 141 rather than the 1 it meant to.
#
# Hence `grep pattern >/dev/null` rather than `grep -q pattern`, which has the same exit status and
# reads to the end; and `awk 'NR<=40'` rather than `head -40`, which prints the same lines and reads
# to the end. Nothing here is allowed to stop early, and `|| true` is not the fix — it would hide a
# real failure of the writer alongside the imaginary one.
requires="$(rpm -qp --requires "$rpm_file")"
provides="$(rpm -qp --provides "$rpm_file")"
files="$(rpm -qp --list "$rpm_file")"
# 1. The bundled-soname trap the spec's provides exclusion sets. If some library under /opt ever
# gains a DT_NEEDED on another library under /opt, elfdeps will put that soname in the requires
# while the exclusion keeps it out of the provides, and the result is a package no host on earth
# can satisfy. Nothing about it looks wrong until an install fails, so it is checked here against
# the actual contents rather than assumed.
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
unsatisfiable=""
for soname in $carried; do
if printf '%s\n' "$requires" | grep -F "$soname(" >/dev/null; then
unsatisfiable="$unsatisfiable $soname"
fi
done
if [ -n "$unsatisfiable" ]; then
echo "This package requires sonames it also carries and does not provide:$unsatisfiable" >&2
echo "That is unsatisfiable on every host, and it means the requires exclusion computed above" >&2
echo "no longer matches what rpm generated — a bundled library outside the top level, or an rpm" >&2
echo "that has stopped honouring __requires_exclude. See build/linux/dodossh.spec." >&2
exit 1
fi
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring
# the macro would break it silently and in the direction nobody looks.
if printf '%s\n' "$provides" | grep -E '^lib.*\.so' >/dev/null; then
echo "This package advertises bundled libraries to the system:" >&2
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2
echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2
exit 1
fi
# 3. elfdeps having actually run. If it silently did nothing — a runner image whose rpm was built
# without libelf would do exactly that — the package would carry no glibc requirement at all and
# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a
# self-contained .NET tree cannot fail to have.
if ! printf '%s\n' "$requires" | grep '^libc\.so\.6' >/dev/null; then
echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
printf '%s\n' "$requires" >&2
exit 1
fi
# 4. The four files a person interacts with, named individually. The tree under /opt is thousands of
# entries and its absence would be caught by size alone; these are the small ones whose absence
# would leave a package that installs, occupies 130 MB and puts nothing on the menu.
for expected in \
"/opt/$dir/DodoSSH" \
"/usr/bin/$bin" \
"/usr/share/applications/$bin.desktop" \
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
do
if ! printf '%s\n' "$files" | grep -xF "$expected" >/dev/null; then
echo "The package was built and $expected is not in it." >&2
printf '%s\n' "$files" | awk 'NR<=40' >&2
exit 1
fi
done
# 5. Nothing world-writable, which on this package is a security property rather than a lint rule.
# The spec sets every mode explicitly and says why; this is the check that it worked, because the
# failure it guards against arrives by inheritance from a publish directory rather than by anybody
# changing this. A world-writable /opt/DodoSSH lets any local account replace the SSH client every
# other account on the machine runs.
#
# Symlinks are excluded because they are always 777 on Linux and the mode means nothing; what the
# link points at is what has permissions.
writable="$(
rpm -qp --queryformat '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm_file" |
grep -v '^l' |
awk '$1 ~ /^.{8}w/ { print }'
)"
if [ -n "$writable" ]; then
echo "This package contains world-writable files:" >&2
printf '%s\n' "$writable" >&2
echo "Any local account could replace them. See the mode block in build/linux/dodossh.spec." >&2
exit 1
fi
echo "==> requires:"
printf '%s\n' "$requires" | sed 's/^/ /'
echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so."
# ◆ THE MANIFEST, WHICH IS WHAT AN INSTALLED CLIENT READS AND NOT WHAT A PERSON READS.
#
# The same arrangement the android job publishes, for the same reason its comment gives: a release's own
# JSON is a large document that changes shape when the forge is upgraded, and the client fetches this on a
# six-hourly timer. A small file this repository writes is a contract; a large one the forge writes is not.
#
# ◆ THE VERSION IN HERE IS THE RPM VERSION, AND THAT IS THE WHOLE POINT OF THE FILE.
#
# LinuxUpdateChannel compares with rpmvercmp, because rpm is what will accept or refuse the install — the
# same argument AndroidChannelManifest makes for versionCode. Publishing the SemVer instead would mean the
# client comparing one thing and the package manager another, and they disagree exactly where it matters:
# 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are the same build, and only the second one sorts below 0.0.1.
#
# The SemVer goes in too and is used for nothing but the banner. A person asked which nightly they are on
# should be able to answer with the string the preferences screen prints, and that screen reads the
# assembly.
#
# ◆ AND THE DIGEST, WHICH THE OTHER TWO CHANNELS HAVE NO NEED OF.
#
# Velopack verifies what it downloads and Android refuses a package whose signature does not match the
# installed one. Nothing about an RPM fetched over HTTPS and handed to PackageKit checks that it is the
# file this release published — TLS authenticates the forge and says nothing about the bytes surviving a
# proxy, a cache or a partial write. So the manifest names the digest and the client refuses anything else.
#
# It is not a signature and must not be read as one: it is written by the same job that writes the package,
# so it proves integrity and not provenance. Provenance here is that the address is a constant pointing at
# the project's own forge — ADR 0013 rule 4 — and a signed repository is what would improve on it.
manifest="$out/linux-$channel.json"
digest="$(sha256sum "$rpm_file" | cut -d' ' -f1)"
cat > "$manifest" <<JSON
{
"rpmVersion": "$rpm_version-$rpm_release",
"version": "$version",
"rpm": "$(basename "$rpm_file")",
"sha256": "$digest"
}
JSON
echo "==> $(basename "$manifest"):"
sed 's/^/ /' "$manifest"
# The lines the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, so the
# desk use above still tells you where the file went.
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "rpm=$rpm_file"
echo "rpmName=$(basename "$rpm_file")"
echo "manifest=$manifest"
echo "manifestName=$(basename "$manifest")"
} >> "$GITHUB_OUTPUT"
fi
echo "$rpm_file"
+60 -10
View File
@@ -169,16 +169,66 @@ Bump ApplicationVersion, commit, move the tag, and run this again.
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
New-Item -ItemType Directory -Path $Staging | Out-Null
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
& dotnet build $Project --no-restore --configuration Release `
-t:SignAndroidPackage `
-p:DodoChannel=release `
-p:AndroidKeyStore=true `
-p:AndroidSigningKeyStore=$KeystorePath `
-p:AndroidSigningKeyAlias=$KeyAlias `
-p:AndroidSigningStorePass=$storePlain `
-p:AndroidSigningKeyPass=$keyPlain
# ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE.
#
# They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is
# not. A process's command line is not a secret on Windows: any process running as this user can read
# every argument of every other with one call —
#
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
#
# — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The
# comment above says the passwords are never stored, and with the arguments written that way it was
# true of the disk and false of everything watching the machine.
#
# env: is .NET for Android's own answer to exactly this, and it names the variable rather than carrying
# the value: MSBuild is handed the string "env:DODOSSH_ANDROID_STOREPASS" and reads the password out of
# the environment itself. The one restriction is $(AndroidPackageFormat) = aab, where env: is not
# supported and file: is the documented alternative; this channel ships an APK, and the guard below
# fails loudly rather than silently signing with the literal string if that ever changes.
#
# Not a perfect channel — an environment block is readable by a process with rights over this one, the
# same as memory is — but it is off the command line, out of the audit trail and out of any log that
# echoes the build invocation, which is the whole of the difference being bought here.
#
# -nodeReuse:false so no MSBuild worker outlives this call holding the variables in its environment.
# The finally clears them from this process either way, and a persistent node would have kept its own
# inherited copy for the next quarter of an hour regardless of what happens here.
$packageFormat = (& dotnet msbuild $Project -getProperty:AndroidPackageFormat -nologo `
-p:DodoChannel=release | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($packageFormat -eq 'aab') {
Stop-With @'
This channel now builds an aab, and the env: prefix this script signs with is not supported for that
format. The signing would silently take "env:DODOSSH_ANDROID_STOREPASS" for the password itself.
Switch both passwords to the file: prefix, which is supported for aab, and make sure whatever writes
that file removes it again.
'@
}
try {
$env:DODOSSH_ANDROID_STOREPASS = $storePlain
$env:DODOSSH_ANDROID_KEYPASS = $keyPlain
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
& dotnet build $Project --no-restore --configuration Release `
-nodeReuse:false `
-t:SignAndroidPackage `
-p:DodoChannel=release `
-p:AndroidKeyStore=true `
-p:AndroidSigningKeyStore=$KeystorePath `
-p:AndroidSigningKeyAlias=$KeyAlias `
-p:AndroidSigningStorePass=env:DODOSSH_ANDROID_STOREPASS `
-p:AndroidSigningKeyPass=env:DODOSSH_ANDROID_KEYPASS
}
finally {
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
# and this runs on the failure path, where it may well not be.
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_STOREPASS', $null)
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_KEYPASS', $null)
}
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
+23 -5
View File
@@ -38,9 +38,11 @@
# xcrun notarytool store-credentials DodoSSH \
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
#
# The forge token is the one real secret, and it is prompted for rather than read from a file or the
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
# minutes a credential that can publish an update spends in a shell's memory the better.
# The forge token is the one real secret, and it is prompted for rather than read from a file or an
# ambient environment variable, and only in the phase that needs it — for the reason the Windows script
# gives: the fewer minutes a credential that can publish an update spends in a shell's memory the better.
# It reaches vpk through VPK_TOKEN, exported for that one call and unset afterwards, because the
# alternative is --token on a command line that is not private. See the comment at the upload itself.
#
# Usage:
# bash scripts/release-macos.sh
@@ -195,10 +197,18 @@ if [ "$UPLOAD" -eq 1 ]; then
# also already hold the Windows package for the same tag, which is the case --merge is really doing
# the work for: without it the second platform to publish a given version fails on a release that
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
#
# No --token in this array, and that is deliberate. It used to be there, and an argument is not a
# private place to put a credential: /proc/<pid>/cmdline is world-readable on Linux, `ps` hands out
# other processes' arguments freely, and whatever agent is auditing process creation on this machine
# keeps its own copy. The comment above is about shell history and was never the whole story.
#
# vpk documents VPK_TOKEN as --token's environment fallback, so the value never becomes an argument.
# Exported for the one call and unset straight after; an environment block is still readable by a
# process with rights over this one, but it is out of every process listing and audit log.
UPLOAD_ARGS=(
upload gitea
--repoUrl "$REPO_URL"
--token "$TOKEN"
--outputDir "$RELEASES_DIR"
--channel "$CHANNEL"
--releaseName "$TAG"
@@ -214,7 +224,15 @@ if [ "$UPLOAD" -eq 1 ]; then
esac
write_step 'Uploading'
dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'
# Unset on the way out whether or not the upload worked, so a failed release does not leave the token
# sitting in this shell for whatever runs next.
export VPK_TOKEN="$TOKEN"
vpk_status=0
dotnet vpk "${UPLOAD_ARGS[@]}" || vpk_status=$?
unset VPK_TOKEN
[ "$vpk_status" -eq 0 ] || stop_with 'vpk upload failed.'
write_step "Published $TAG."
exit 0
+31 -5
View File
@@ -16,9 +16,11 @@
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
so the bytes that reach users are the bytes that were installed and checked.
The token is prompted for rather than read from a file or an environment variable, and only in the phase
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
in a shell's memory the better.
The token is prompted for rather than read from a file or an ambient environment variable, and only in
the phase that needs it — the build does not, and the fewer minutes a credential that can publish an
update spends in a shell's memory the better. It reaches vpk through VPK_TOKEN, set for that one call
and cleared afterwards, because the alternative is --token on a command line every process on this
machine can read. See the comment at the upload itself.
.PARAMETER Upload
Publish the packages already in Releases/ instead of building.
@@ -152,10 +154,24 @@ try {
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
# fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
# same tag, so a release candidate is a prerelease in both channels or in neither.
#
# ◆ NO --token HERE. IT WAS AN ARGUMENT, AND AN ARGUMENT IS PUBLIC ON THIS MACHINE.
#
# The comment above says the token never lands in shell history, which was true and was not the
# whole story: it went straight into vpk's command line, and any process running as this user can
# read that off a running process with one call —
#
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
#
# — with process-creation auditing and EDR agents keeping their own copy afterwards. A token that
# can publish an update every installed copy will fetch and run deserves better than that.
#
# vpk documents VPK_TOKEN as --token's environment fallback, so the flag simply goes away and the
# value never becomes an argument at all. An environment block is still readable by a process with
# rights over this one; what it is not is a string sitting in a process listing and an audit log.
$uploadArgs = @(
'upload', 'gitea',
'--repoUrl', $RepoUrl,
'--token', $token,
'--outputDir', $ReleasesDir,
'--channel', $Channel,
'--releaseName', $tag,
@@ -169,7 +185,17 @@ try {
}
Write-Step 'Uploading'
& dotnet vpk @uploadArgs
try {
$env:VPK_TOKEN = $token
& dotnet vpk @uploadArgs
}
finally {
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
# and this runs on the failure path, where it may well not be.
[Environment]::SetEnvironmentVariable('VPK_TOKEN', $null)
}
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
Write-Step "Published $tag."
@@ -26,9 +26,6 @@ internal sealed class GetMetaEndpoint(
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
internal const int SyncProtocolVersion = 1;
/// <summary>Feature flag for the relay.</summary>
internal const string RelayFeature = "relay";
/// <inheritdoc />
public override void Configure()
{
@@ -50,7 +47,7 @@ internal sealed class GetMetaEndpoint(
List<string> features = ["teams"];
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
@@ -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,722 @@
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);
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, 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 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, 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 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, 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 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;
}
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;
}
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
if (now >= idleDeadline)
{
return new Ending(
RelayCloseReason.IdleTimeout,
RelayProtocol.IdleTimeoutCloseCode,
"This relay session carried nothing for the idle timeout.");
}
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, lifetime.ApplicationStopping)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
try
{
await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The shutdown began. Round again to start the drain.
}
catch (OperationCanceledException)
{
return null;
}
}
return null;
}
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);
}
}
}
@@ -0,0 +1,94 @@
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);
}
@@ -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,111 @@
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 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>
internal Lease? TryAdmit(Guid userId)
{
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++;
return new Lease(this, userId);
}
}
private void Release(Guid userId)
{
lock (gate)
{
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) : IDisposable
{
private int released;
/// <inheritdoc />
public void Dispose()
{
if (Interlocked.Exchange(ref released, 1) == 0)
{
registry.Release(userId);
}
}
}
}
@@ -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);
}
}
+3
View File
@@ -49,6 +49,9 @@ builder.Services.AddSingleton<VaultEventHub>();
builder.Services.AddSingleton<IVaultEventPublisher>(
provider => provider.GetRequiredService<VaultEventHub>());
// 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
// DbContext, and a singleton would capture one for the lifetime of the process.
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
+27 -3
View File
@@ -54,6 +54,13 @@ internal static class Configuration
+ "setting exists to provide.")
.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>()
.BindConfiguration(RelayOptions.SectionName)
.ValidateDataAnnotations()
@@ -63,11 +70,28 @@ internal static class Configuration
.Validate(
options => options.IdleTimeout < options.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();
return services;
}
/// <summary>
/// Warns about configuration that is individually valid but dangerous in combination.
/// </summary>
+39 -2
View File
@@ -115,13 +115,50 @@ public sealed class RelayOptions
/// Whether RFC1918 and other private ranges may be dialled.
/// </summary>
/// <remarks>
/// <para>
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
/// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. Loopback,
/// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
/// </para>
/// <para>
/// Governs the private ranges only — RFC1918, carrier-grade NAT (which is where a Tailnet lives) and
/// IPv6 unique-local — and never the list <c>RelayAddressPolicy</c> refuses with no configuration
/// escape: loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses.
/// Nothing here can re-open those, which is what ADR 0004 §3 means by non-overridable.
/// </para>
/// </remarks>
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>
[Range(1, 1000)]
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
@@ -1,6 +1,7 @@
using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Relay;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams;
using DodoSSH.Contracts;
@@ -45,6 +46,8 @@ internal static class EndpointRegistration
typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint),
typeof(VaultEventsEndpoint),
typeof(RelayTicketEndpoint),
typeof(RelayConnectEndpoint),
typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint),
typeof(UpdateTeamEndpoint),
@@ -65,7 +68,6 @@ internal static class EndpointRegistration
// Registered as each feature lands:
// Identity — key rotation, passphrase change
// Vaults — per-item ACLs
// Relay — tickets and the WebSocket
// Audit, Admin
});
+59
View File
@@ -0,0 +1,59 @@
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<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;
}
}
+30 -2
View File
@@ -86,7 +86,13 @@ public sealed partial class DodoSshApp : Avalonia.Application
var shell = new PhoneShell();
var viewModel = ComposeShell(
paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell));
paths,
caches,
workspace,
knownHosts,
connections,
ClipboardWriter(shell),
ClipboardReader(shell));
shell.DataContext = viewModel;
@@ -176,6 +182,26 @@ public sealed partial class DodoSshApp : Avalonia.Application
}
};
/// <summary>Reads this phone's clipboard.</summary>
/// <remarks>
/// <para>
/// The other direction, looked up per call for the same reason and with the same fall-through. One
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
/// </para>
/// <para>
/// Wired on this head although a phone has no right button, and not for symmetry: this head runs the same
/// renderer page, and a device with a mouse, a trackpad case or a desktop dock has a secondary click.
/// Whether Android's WebView delivers one as a DOM <c>contextmenu</c> event is unverified — see
/// <c>docs/manual-checks.md</c> 11.13 — and leaving this out in the meantime would make the paste half
/// the one thing that worked on one head and silently did nothing on the other.
/// </para>
/// </remarks>
private static Func<Task<string?>> ClipboardReader(Visual surface) =>
async () => TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard }
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
: null;
/// <remarks>
/// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
/// the platform graph, and below it is the shell every head shares.
@@ -186,7 +212,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
SshNetConnectionFactory connections,
Func<string, Task> copyToClipboard)
Func<string, Task> copyToClipboard,
Func<Task<string?>> readClipboard)
{
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
// key. A straight implementation of the interface the session layer has always taken.
@@ -236,6 +263,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
deviceName: PhoneEnvironment.DeviceName,
copyToClipboard: copyToClipboard,
readClipboard: readClipboard,
updates: updates);
@@ -90,6 +90,11 @@ public sealed class MainActivity : AvaloniaMainActivity
PhoneEnvironment.CurrentActivity = this;
AndroidRedirectCallback.Abandon();
// And the keyboard may have gone away while this activity was not on screen, without Avalonia's
// input pane raising anything to say so. PhoneShell re-reads the inset from this. See
// PhoneEnvironment.Resumed, which carries the whole of why.
PhoneEnvironment.RaiseResumed();
}
/// <inheritdoc />
@@ -100,6 +100,35 @@ internal static class PhoneEnvironment
/// </remarks>
public static global::Android.App.Activity? CurrentActivity { get; set; }
/// <summary>Raised each time the launcher activity comes back to the foreground.</summary>
/// <remarks>
/// <para>
/// ◆ <b>Exists because the software keyboard can go away without Avalonia being told.</b>
/// <c>IInputPane.StateChanged</c> is the only signal <c>PhoneShell</c> has for the keyboard opening and
/// closing, and it is not raised when Android dismisses the keyboard on the way out of the app — the
/// activity is being paused, and the pane is simply gone by the time anything could have reported it.
/// So the inset that holds the interface clear of the keyboard was still applied on the way back in,
/// and the whole phone drew an entire keyboard's height above the bottom of a screen with no keyboard
/// on it. Reproduced every time: open the keyboard anywhere, press Home, return — 838 of 2340 pixels
/// dead, and staying that way until the keyboard was opened and closed again.
/// </para>
/// <para>
/// An event here rather than a call into the view, because the view is Avalonia's and this is Android's:
/// <c>MainActivity</c> already owns <c>OnResume</c> and already writes <see cref="CurrentActivity"/>
/// from it, and a static event is the same shape of hand-off one step further. Anything else that needs
/// to re-read platform state on the way back in can subscribe without <c>MainActivity</c> learning what
/// it is.
/// </para>
/// <para>
/// Subscribers are responsible for unsubscribing — this is a static event and holds whatever it is
/// given for the life of the process. <c>PhoneShell</c> attaches and detaches with its visual tree.
/// </para>
/// </remarks>
public static event EventHandler? Resumed;
/// <summary>Announces that the launcher activity is foreground again.</summary>
internal static void RaiseResumed() => Resumed?.Invoke(null, EventArgs.Empty);
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
public static Context Require() =>
context ?? throw new InvalidOperationException(
@@ -0,0 +1,155 @@
using global::Android.Views;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
/// back.
/// </summary>
/// <remarks>
/// <para>
/// <b>This replaces <c>TerminalFocus</c>, and it is the opposite move.</b> That class handed Android's own
/// view focus <em>back</em> to the <c>WebView</c> after Avalonia's <c>DispatchTouchEvent</c> took it, because
/// the input connection the keyboard was attached to belonged to the page. Under ADR 0015 it no longer does:
/// <see cref="Views.TerminalInputSurface"/> is the input target, and it is an Avalonia control, so the focus
/// the platform requests for Avalonia's view after every handled touch is now the focus we want. The fight
/// ends by removing one of the contestants rather than by refereeing it faster.
/// </para>
/// <para>
/// <b>Non-focusable, not non-interactive, and the distinction is the whole design.</b> Focusability governs
/// where keyboard and D-pad input go; it has nothing to do with touch delivery. A <c>WebView</c> with
/// <c>Focusable=false</c> still receives, scrolls and hit-tests every touch it is given — so the page keeps
/// its <c>touch-action: pan-y</c> scrollback exactly as before — but it can no longer take an input
/// connection, and Chromium's own <c>showSoftInput</c> for the focused DOM node cannot succeed from an
/// unfocused view. That is precisely the split wanted: the page keeps the finger, Avalonia keeps the
/// keyboard.
/// </para>
/// <para>
/// <b>Why a tap has to be reported at all.</b> With the page unable to raise a keyboard, the ordinary way
/// back after the system back gesture lowers one — touch the terminal — would otherwise do nothing. The
/// listener is attached with <c>Handled = false</c> throughout, so it observes without consuming: the
/// <c>WebView</c>'s own <c>OnTouchEvent</c> runs after it exactly as if nothing were listening.
/// </para>
/// <para>
/// A tap is distinguished from a scroll the way every Android view does it, by
/// <c>ViewConfiguration.ScaledTouchSlop</c>. Without that test, dragging the scrollback would raise the
/// keyboard over the very output being scrolled to.
/// </para>
/// <para>
/// 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
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
/// terminal. Every step is allowed to be absent — there is no activity while backgrounded, and no
/// <c>WebView</c> until the terminal surface has been shown once.
/// </para>
/// </remarks>
internal static class RendererFocus
{
/// <summary>The view already configured, so that showing the terminal twice does not listen twice.</summary>
/// <remarks>
/// Weak, because this is a static holding an Android view: a strong reference would keep the whole view
/// hierarchy, and the activity behind it, alive for the life of the process.
/// </remarks>
private static WeakReference<global::Android.Webkit.WebView>? configured;
/// <summary>
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
/// taps it.
/// </summary>
/// <remarks>
/// 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
/// 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>).
/// </remarks>
public static void Suppress(Action onTapped)
{
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
{
return;
}
if (FindWebView(decor) is not { } webView)
{
return;
}
if (configured is not null && configured.TryGetTarget(out var already) && ReferenceEquals(already, webView))
{
return;
}
webView.Focusable = false;
webView.FocusableInTouchMode = false;
webView.ClearFocus();
WatchForTaps(webView, onTapped);
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
}
/// <summary>
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
/// </summary>
/// <remarks>
/// 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:
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
/// </remarks>
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
{
// The fallback is the platform's own historical default, for a context that has somehow gone.
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
var downX = 0f;
var downY = 0f;
var moved = false;
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)
{
return;
}
switch (motion.Action)
{
case MotionEventActions.Down:
downX = motion.GetX();
downY = motion.GetY();
moved = false;
break;
case MotionEventActions.Move:
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
break;
case MotionEventActions.Up when !moved:
onTapped();
break;
}
};
}
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
{
for (var i = 0; i < parent.ChildCount; i++)
{
switch (parent.GetChildAt(i))
{
case global::Android.Webkit.WebView webView:
return webView;
case ViewGroup child when FindWebView(child) is { } found:
return found;
}
}
return null;
}
}
@@ -1,79 +0,0 @@
using global::Android.Views;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Hands native focus back to the terminal's WebView after Avalonia chrome took it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> the
/// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does
/// not own. The accessory row's keys are already <c>Focusable=false</c> — see TerminalScreen — so Avalonia's
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>:
/// <c>AvaloniaView.DispatchTouchEvent</c> (decompiled from Avalonia.Android 12.1.1) ends every handled
/// touch — DOWN and UP alike — with a <c>RequestFocus()</c> for Avalonia's own view. The WebView's input
/// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text,
/// and the inset churn that follows can leave it sitting on top of the very row that was tapped.
/// </para>
/// <para>
/// <b>Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.</b>
/// The first version called <c>RequestFocus()</c> from the keys' own Click handlers, which fire
/// <em>inside</em> the UP event's dispatch — and the platform's own request runs <em>after</em> dispatch
/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
/// about to be made stale by the very mechanism this exists to counter.
/// </para>
/// <para>
/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining
/// native focus re-establishes the same input connection and the keyboard settles back to what it was.
/// </para>
/// <para>
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, because the
/// control does not expose its platform child and this application only ever has the one WebView — the
/// walk's first match is necessarily the terminal. Every step is allowed to be absent, exactly as
/// <see cref="SoftKeyboard.Hide"/>'s are: no activity while backgrounded, no WebView while the terminal
/// surface has never been shown, and nothing to do in either case.
/// </para>
/// </remarks>
internal static class TerminalFocus
{
public static void Return()
{
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
{
return;
}
if (FindWebView(decor) is not { } webView)
{
return;
}
webView.Post(() =>
{
if (!webView.IsFocused)
{
webView.RequestFocus();
}
});
}
private static View? FindWebView(ViewGroup parent)
{
for (var i = 0; i < parent.ChildCount; i++)
{
switch (parent.GetChildAt(i))
{
case global::Android.Webkit.WebView webView:
return webView;
case ViewGroup child when FindWebView(child) is { } found:
return found;
}
}
return null;
}
}
@@ -167,10 +167,124 @@
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
<Setter Property="FontSize" Value="20" />
</Style>
<!--
◆ THE FILL BELOW IS NOT DECORATION. IT IS UNDOING ONE FLUENT PUTS THERE.
`accent` is not a name this theme invented — Fluent ships a `Button.accent` style of its own, and a
button classed `icon accent` matches it. So these three buttons were being painted with Fluent's
system-accent slab, #6275AC, a blue that appears nowhere in Palette.axaml and reads as a control from
a different application sitting in this one's header. Measured off a screenshot rather than guessed:
the fill was #6275AC where the accent is #5D42DE.
The rule above could not stop it, because it sets Foreground and FontSize and Fluent's fill is on the
ContentPresenter — which is also why `Button.icon`'s own Background="Transparent" does not reach it.
App.axaml has carried the desktop's answer to exactly this since v5b: override the ContentPresenter,
not the Button. This head simply never had one, so the desktop looked right and the phone did not.
Transparent rather than the desktop's gradient, and the difference is the control rather than an
inconsistency. The desktop's Button.accent is a filled primary slab — this head has one of those too,
and it is Button.primary. `icon accent` is a 44-pixel glyph in a header row beside other glyphs; what
"accent" asks for there is the accent *colour on the mark*, which is what the rule above gives it. A
slab would make one header icon a button and its neighbours not.
Pressed is deliberately not restated: Button.icon:pressed below sets ChromeHover on every icon button
and, coming later in this file, wins over this rule for the moment a finger is down. That is the
feedback the plain icon buttons give and there is no reason this one should differ.
-->
<Style Selector="Button.icon.accent /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<Style Selector="Button.icon.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<Style Selector="Button.icon.accent:disabled /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.icon:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
</Style>
<!--
============ one key of the terminal's accessory keypad ============
A class rather than eleven sets of attributes assigned in code, and the move out of the code-behind is
most of what makes the latched state honest: Ctrl on and Ctrl off used to be two Background values
written by hand in TerminalScreen.axaml.cs, which meant the row named two of this palette's brushes
itself and any third state would have named a third. It is `latched` here, and the code toggles a class.
── the numbers, and why they are these ─────────────────────────────────────────────────────────────────
40 tall, where the row this replaced was 30. Android asks for 48 on a touch target and every terminal on
the platform lands near it; 30 was chosen to save a strip of the terminal above and took the keypad past
the point of working, which is the wrong end of that trade. 40 with a 2-pixel margin gives 44 of pitch,
which is the same figure TextBox.field uses for a box a thumb has to hit.
HorizontalAlignment Stretch, because these live in UniformGrid cells and the cell is the share of the
width the key is entitled to. Left to Center a key would shrink to its label and "|" would be a sliver
between two wide neighbours — which is the layout the flexed row exists to stop.
── pressed has to be visible, and it was not ───────────────────────────────────────────────────────────
These keys are Focusable="False" so that using one does not take the keyboard away from the terminal
(see TerminalScreen.axaml.cs), and a button that never takes focus also never shows the focus adorner
Fluent would otherwise give it. That left a press with no feedback at all on a control whose entire job
is to be pressed while somebody is looking at the line of output above it. The pressed fill below is the
only thing that says a key was hit — and on a keypad over a remote shell, "did that send?" is a question
worth answering in the interface rather than in the scrollback.
-->
<Style Selector="Button.hotkey">
<Setter Property="Height" Value="40" />
<Setter Property="MinHeight" Value="40" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="{StaticResource Panel}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.hotkey /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Panel}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
</Style>
<Style Selector="Button.hotkey:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Panel}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
</Style>
<Style Selector="Button.hotkey:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
</Style>
<!--
Latched Ctrl. The same fill a press gives, held rather than flashed — a modifier that is on and does not
look on is how somebody sends ^L to a database prompt believing they typed an l.
-->
<Style Selector="Button.hotkey.latched">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<Style Selector="Button.hotkey.latched /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
</Style>
<Style Selector="Button.hotkey:disabled /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Panel}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
</Style>
<Style Selector="Button.hotkey:disabled">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<!--
The floating action button the design has asked for since v2, and which this theme refused twice — on
the grounds that it would open an editor this head had not got, and that an unused style is a claim the
@@ -398,6 +512,58 @@
<Setter Property="FontSize" Value="12" />
</Style>
<!--
◆ The two port boxes, which are NumericUpDowns and were the only unthemed controls on this head.
They sit in a two-column Grid with a plain TextBox.field beside them — port next to username in the host
editor, and port next to default-user in the group editor — so the mismatch was not subtle: a bright
Fluent border and a near-black fill against this theme's #101019 and hairline, at the one place on the
screen where the two are a hand's width apart and directly comparable.
Not `Classes="field"` on the two call sites, because that class is a TextBox class and this is not a
TextBox: NumericUpDown is a ButtonSpinner wrapping one, so half the chrome being corrected belongs to
the spinner and would not take a TextBox setter. Typing the control instead also means a third port box
added later is right without anybody remembering, which is the property the desktop's own bare
`Selector="NumericUpDown"` in App.axaml has had since it was written.
Three selectors rather than one for the same reason Button.icon.accent needs two: Fluent puts the fill
and the border on template parts, and a value set on the control alone is a TemplateBinding the theme's
own wins over. The numbers are TextBox.field's, exactly — 44 tall, 10 radius, Field on BorderMid — so
the pair reads as one row of two boxes rather than as a box and a visitor.
-->
<Style Selector="NumericUpDown">
<Setter Property="Height" Value="44" />
<Setter Property="MinHeight" Value="44" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{StaticResource Field}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12" />
</Style>
<Style Selector="NumericUpDown /template/ ButtonSpinner">
<Setter Property="Background" Value="{StaticResource Field}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="MinHeight" Value="44" />
</Style>
<!--
The inner box gives its chrome up entirely: the spinner above is drawing the border and the fill, and a
TextBox that kept its own would put a second rounded rectangle a pixel inside the first.
-->
<Style Selector="NumericUpDown /template/ TextBox">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="14,0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12" />
</Style>
<!--
Every box on this head that takes a secret, and it is a class rather than two attributes repeated five
times because the two attributes are not interchangeable and both are needed.
@@ -450,6 +616,17 @@
<Setter Property="Fill" Value="{StaticResource Warn}" />
</Style>
<!--
Red for a shell whose connection broke, splitting the grey that used to cover every dead tab. A shell
somebody exited and a shell that was cut off are the two dead states with the least in common, and on a
phone the difference matters more than it does on a desk: a mobile link drops on its own, and the tab
that went quiet while the screen was off is the one worth dialling again. See
TerminalTabViewModel.IsDropped.
-->
<Style Selector="Ellipse.dot.dropped">
<Setter Property="Fill" Value="{StaticResource Danger}" />
</Style>
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
<Style Selector="TextBlock.mono">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
@@ -462,10 +639,29 @@
<Setter Property="FontSize" Value="9.5" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!--
◆ Wraps, like .body below it, and the omission was doing real damage rather than none.
A TextBlock that neither wraps nor trims does not shrink to fit and does not report that it did not:
it lays out at its full single-line width and the parent clips whatever runs past the edge. So a
.detail long enough to overflow ended mid-word against the screen, with no ellipsis to say anything
was missing — "A machine to connect to, filed under a group if you" on the add sheet, which reads as
a sentence somebody forgot to finish rather than as text that was cut.
It is a style rather than an attribute on the ones that overflow today, because which of the fifty-odd
.detail blocks overflows is a function of the device's width and the user's font scale, not of the
string: every one of them is a candidate at 320dp that is fine at 411dp, and fixing the handful that
happen to be visibly wrong on a Pixel 5 would leave the rest to be found one narrow phone at a time.
The nine that genuinely want one line — list rows and log lines, where a wrapped detail would push the
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.
-->
<Style Selector="TextBlock.detail">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.body">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
@@ -48,7 +48,12 @@
the refusal has no continue button here either.
-->
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto">
<!--
Nine rows since the disconnect strip arrived; it is row 7, between the listing and the action bar, so
that what it says is next to the buttons it replaces the point of. Auto and empty except while the
session has been lost, so an ordinary browse pays nothing for it.
-->
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto,Auto">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
@@ -78,7 +83,13 @@
</Grid>
<!-- ============ opening one ============ -->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding !IsConnected}">
<!--
ShowsRemoteInvitation rather than !IsConnected, which stopped being the same question when a
connection could be lost rather than only closed: a session that went away leaves its listing on
screen, and this card springing back over it would hide the one thing the strip below is offering to
put the user back into. See TransfersViewModel.ShowsRemoteContents.
-->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding ShowsRemoteInvitation}">
<StackPanel Spacing="10">
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
@@ -186,7 +197,8 @@
IME action, and a path box whose only commit was a key that may say "newline" would be a box nothing
could be submitted from. The Enter binding stays for the hardware keyboards this head does meet.
-->
<Grid Grid.Row="3" ColumnDefinitions="*,8,Auto" Margin="14,0,14,6" IsVisible="{Binding IsConnected}">
<Grid Grid.Row="3" ColumnDefinitions="*,8,Auto" Margin="14,0,14,6"
IsVisible="{Binding ShowsRemoteContents}">
<TextBox Grid.Column="0" Classes="field" Text="{Binding RemotePathDraft}"
PlaceholderText="/path/to/somewhere">
<TextBox.KeyBindings>
@@ -197,9 +209,24 @@
Command="{Binding GoToTypedRemotePathCommand}" />
</Grid>
<!--
◆ How many are gathered up, and the one gesture that gathers or releases them all. Only while
something is — an always-present row would spend 30 pixels of a listing on a count of zero.
-->
<Grid Grid.Row="3" ColumnDefinitions="Auto,*" Margin="14,0,14,6" VerticalAlignment="Bottom"
IsVisible="{Binding HasCheckedRemote}">
<Button Grid.Column="0" Classes="secondary" MinHeight="36" Padding="12,0"
Command="{Binding ToggleAllRemoteCommand}">
<TextBlock Classes="label" FontSize="9" Text="ALL / NONE" />
</Button>
<TextBlock Grid.Column="1" Classes="mono" FontSize="12" Margin="10,0,0,0"
VerticalAlignment="Center" Foreground="{StaticResource AccentText}"
Text="{Binding CheckedRemoteLabel}" />
</Grid>
<!-- ============ where we are ============ -->
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
Command="{Binding RemoteUpCommand}">
@@ -265,7 +292,7 @@
<!-- ============ the listing ============ -->
<Panel Grid.Row="6">
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
<TextBlock Classes="body" IsVisible="{Binding ShowsRemoteInvitation}" Margin="24,12"
VerticalAlignment="Top" Text="{Binding Status}" />
<!--
@@ -282,7 +309,7 @@
selection navigate would send a single desktop click walking into a directory.
-->
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
Background="Transparent" BorderThickness="0" IsVisible="{Binding IsConnected}"
Background="Transparent" BorderThickness="0" IsVisible="{Binding ShowsRemoteContents}"
Tapped="OnRemoteEntryTapped">
<ListBox.Styles>
<Style Selector="ListBoxItem">
@@ -299,15 +326,27 @@
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<Border MinHeight="52" Padding="10,7">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<!--
◆ The tick, which on this head is the only multi-selection there can be: a phone has no
ctrl-click and no shift-click. It is not the list's selection — that stays "the row I am
looking at", which RENAME, MOVE and PERMS act on one at a time — and this is "the rows I
have gathered up", which SAVE and DELETE take. See RemoteEntryRowViewModel.IsChecked.
Its own 40-pixel column so a thumb aimed at it does not open the directory beside it: the
row's own tap navigates, which is this head's one behaviour the desktop does not share.
-->
<CheckBox Grid.Column="0" Width="40" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}" Click="OnRemoteRowChecked" />
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
VerticalAlignment="Center" FontSize="12" Text="▸"
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
VerticalAlignment="Center" FontSize="12" Text="·"
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
<StackPanel Grid.Column="2" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7">
@@ -318,7 +357,7 @@
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Modified}"
<TextBlock Grid.Column="3" Classes="detail" Text="{Binding Modified}"
VerticalAlignment="Center" />
</Grid>
</Border>
@@ -327,9 +366,50 @@
</ListBox>
</Panel>
<!-- ============ what the screen says once the session has stopped ============ -->
<!--
The phone's half of the desktop's SessionEndedBar, and it binds this screen's own view model rather
than the shell's — this control's DataContext is TransfersViewModel, where the desktop's strip sits in
MainWindow beside a status bar and can ask which surface it is on. The facts are the same two:
TransfersViewModel.LostConnection and the reconnect that goes back to the directory showing.
A phone needs it more than a desk does, and for the reason its terminal does: a mobile link drops on
its own and the screen goes off, so this session is discovered later with a listing on it and no
account of why nothing works. The listing stays deliberately — see ShowsRemoteContents.
-->
<Border Grid.Row="7" IsVisible="{Binding IsConnectionLost}" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Danger}" BorderThickness="0,1,0,0" Padding="12,10">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Classes="dot" Classes.dropped="True" Width="7" Height="7" VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="9" Text="DISCONNECTED" />
</StackPanel>
<!--
Two lines maximum, for the reason the terminal's strip caps its own: a transport error can hand
over whatever the operating system called it, and a strip that grew to fit one would eat the
listing above it on a screen that has none to spare.
-->
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
Text="{Binding LostConnection}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding ReconnectCommand}">
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
</Button>
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding DisconnectCommand}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
</Button>
</StackPanel>
</StackPanel>
</Border>
<!-- ============ what to do with the chosen entry ============ -->
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
<Border Grid.Row="7" 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">
<StackPanel Spacing="9">
@@ -381,6 +461,75 @@
</StackPanel>
</Border>
<!--
◆ The mode, as nine boxes and the number they add up to — in the place the other two questions
already occupy and never at the same time as either. See RemotePermissionsRequest for why it is
boxes rather than a field to type 755 into, and TransfersScreen.axaml for the desktop's own copy
of the same form.
Compact rather than a second layout: the three columns are the mode's own three triples, and at
360dp they are exactly what fits. The octal is printed underneath as it changes, which is what
lets somebody who came here to type chmod check the boxes they just ticked.
-->
<Border IsVisible="{Binding IsChangingRemotePermissions}" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
Padding="13,11">
<StackPanel Spacing="6">
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemotePermissions.Question}" />
<TextBlock Classes="detail" TextWrapping="Wrap"
Text="{Binding PendingRemotePermissions.FullPath}" />
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,44,44,44" Margin="0,2">
<TextBlock Grid.Row="0" Grid.Column="1" Classes="detail" Text="read"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="2" Classes="detail" Text="write"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="3" Classes="detail"
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="12" Text="owner"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="12" Text="group"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="12" Text="others"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
</Grid>
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource AccentText}"
Text="{Binding PendingRemotePermissions.Preview}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" Content="APPLY"
Command="{Binding ConfirmRemotePermissionsCommand}"
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelRemotePermissionsCommand}" />
</Grid>
</StackPanel>
</Border>
<!--
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
@@ -402,7 +551,7 @@
Foreground="{StaticResource Accent}"
Background="{StaticResource Raised}" />
<TextBlock Classes="detail" Text="{Binding Progress}"
TextTrimming="CharacterEllipsis" />
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
@@ -457,11 +606,23 @@
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="DELETE"
<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}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
</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}"
@@ -35,6 +35,15 @@ internal sealed partial class FilesScreen : UserControl
/// read its size the same gesture as fetching it.
/// </para>
/// </remarks>
/// <summary>Tells the screen a row's tick moved, so the count above the listing keeps up.</summary>
/// <remarks>
/// The desktop's own handler, for the reason given there: the count is a property of the collection
/// rather than of any row, and each listing replaces every row it has. See
/// <c>TransfersViewModel.RemoteChecksChanged</c>.
/// </remarks>
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) =>
(DataContext as TransfersViewModel)?.RemoteChecksChanged();
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
{
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
@@ -49,12 +49,29 @@
<TextBlock Classes="title" Text="UNKNOWN HOST KEY" FontSize="13" />
<!--
◆ The port and the full stop are one Run, and that is the whole of the fix.
Avalonia puts a space between adjacent Runs, which is what makes "First contact with", the
host and the separator read as a sentence without any of them carrying padding. The trailing
Run began with a full stop, so it got that space too and the sheet said
First contact with 10.0.2.2 · 2222 . Nothing in this keychain…
— a gap before the stop, on the one dialogue in this application whose entire job is to be
read carefully before somebody answers it. StringFormat welds the stop to the number inside a
single Run, where no inter-Run space can get in, and the sentence that follows starts a Run of
its own so it still gets the space it wants.
The `{}` prefix is required: a StringFormat beginning with a brace would otherwise be parsed
as a markup extension.
-->
<TextBlock Classes="body" Margin="0,6,0,0">
<Run Text="First contact with" />
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
<Run Text="·" />
<Run Text="{Binding PendingHostKey.Port}" />
<Run Text=". Nothing in this keychain has approved this key." />
<Run Text="{Binding PendingHostKey.Port, StringFormat='{}{0}.'}" />
<Run Text="Nothing in this keychain has approved this key." />
</TextBlock>
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
@@ -465,7 +465,7 @@
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
TextTrimming="CharacterEllipsis" />
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
</StackPanel>
@@ -1199,19 +1199,18 @@
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".
◆ AND IT SAYS, FIRST, THAT IT BUYS NOTHING TODAY. The relay's server half is built and this
client has no path to it — the connect path dials the address directly whether this is ticked or
not — so the box as it stood spent that concession and delivered nothing for it. Left tickable
rather than disabled, so a host already carrying the flag can lose it. See
It also says the two things a user would otherwise find out by failing: the server, not this
phone, has to be able to reach the address, and a relayed host does not fall back to dialling
directly, so it connects only while the server is reachable. See
docs/reaching-a-host-you-cannot-dial.md.
-->
<CheckBox IsChecked="{Binding EditorRelayEnabled}" MinHeight="44">
<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" />
</CheckBox>
<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." />
<!--
This page covers the whole screen while it is open, which is why it needs a Status line of its
@@ -287,7 +287,7 @@
</Border>
</StackPanel>
<!-- What is known *about* an item, never the secret. -->
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
@@ -106,11 +106,11 @@
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
Text="{Binding Address}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
<!--
TextDim rather than the `detail` class's own faint step. This block is drawn on
@@ -124,7 +124,7 @@
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
Text="·" />
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
Text="{Binding DeviceName}" TextTrimming="CharacterEllipsis" />
Text="{Binding DeviceName}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<!-- A refused host key is the one outcome worth a colour of its own. -->
@@ -158,7 +158,7 @@
</StackPanel>
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
Text="{Binding ItemLabel}" TextTrimming="CharacterEllipsis" />
Text="{Binding ItemLabel}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
<!-- The names of the fields that changed, never their values. -->
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasChangedFields}"
@@ -391,11 +391,15 @@ internal sealed partial class PhoneShell : UserControl
ApplyKeyboardInset(keyboard);
}
}
PhoneEnvironment.Resumed += OnActivityResumed;
}
/// <inheritdoc />
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
PhoneEnvironment.Resumed -= OnActivityResumed;
if (TopLevel.GetTopLevel(this) is { } top)
{
top.BackRequested -= OnBackRequested;
@@ -410,6 +414,43 @@ internal sealed partial class PhoneShell : UserControl
base.OnDetachedFromVisualTree(e);
}
/// <summary>
/// Re-reads the keyboard inset on the way back into the application.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The margin can be stale here and there is no other moment it gets corrected.</b> Android takes
/// the software keyboard away when this activity stops, and Avalonia's input pane raises no
/// <c>StateChanged</c> for it — the pane is gone with the window rather than closed in front of one. So
/// <see cref="ApplyKeyboardInset(double)"/> last ran for a keyboard that was up, and the interface comes
/// back holding a whole keyboard's worth of empty space it no longer needs. It is not subtle and it does
/// not recover: a third of the display, on every screen, until something opens and closes the keyboard.
/// </para>
/// <para>
/// Asking the pane rather than assuming zero, because resuming does not always mean the keyboard went.
/// A permission dialogue, the biometric sheet and the sign-in browser all pause this activity and can
/// all hand it back with the keyboard exactly as it was — and a handler that flattened the inset on the
/// way in would then strand the focused box under a keyboard that is genuinely there. The pane knows
/// which of the two happened; this only has to ask it again.
/// </para>
/// <para>
/// Posted rather than run inline. <c>OnResume</c> is early — the window has not necessarily been laid
/// out again and the pane's own state is settled by the insets that arrive with the next frame, so
/// reading it on this turn can return the state that is on the way out. Background priority puts the
/// read after that.
/// </para>
/// </remarks>
private void OnActivityResumed(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(
() =>
{
if (keyboard is not null)
{
ApplyKeyboardInset(keyboard);
}
},
DispatcherPriority.Background);
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
@@ -163,6 +163,8 @@
Text="Text size is on the terminal itself, where the text is — the phone puts A− and A+ in the shell's own bar rather than a step away from what they resize." />
<TextBlock Classes="body"
Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." />
<TextBlock Classes="body"
Text="Importing a Hetzner Cloud project would work here — it is a token and a network request, not a file — and the wizard behind it is already shared. What is missing is a phone layout for it: the desktop draws a five-column table, and the screen it would become is not the desktop's with narrower columns. Until then it is a thing to do on a desktop; the hosts it makes sync here like any other." />
<TextBlock Classes="body"
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
</StackPanel>
@@ -0,0 +1,16 @@
namespace DodoSSH.Client.Android.Views;
/// <summary>
/// One keystroke's worth of bytes, on its way from <see cref="TerminalInputSurface"/> to the shell.
/// </summary>
/// <remarks>
/// Bytes rather than text, because half of what a terminal receives has no text form: a cursor key is an
/// escape sequence and Ctrl-C is a single byte below the printable range. Encoding at the surface — where
/// the key is still known — rather than downstream is what keeps the send path from having to guess which
/// kind it was handed.
/// </remarks>
/// <param name="bytes">The encoded keystroke.</param>
internal sealed class TerminalInputEventArgs(byte[] bytes) : EventArgs
{
public byte[] Bytes { get; } = bytes;
}
@@ -0,0 +1,335 @@
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.TextInput;
using Avalonia.Media;
namespace DodoSSH.Client.Android.Views;
/// <summary>
/// The terminal's keyboard, as an Avalonia control that draws nothing.
/// </summary>
/// <remarks>
/// <para>
/// <b>This exists so that the software keyboard is configured by us rather than by Chromium</b>, and that
/// single sentence is the whole of ADR 0015. A phone keyboard over a terminal has to be told to stop
/// autocorrecting, stop predicting, stop capitalising and stop offering a suggestion strip, and on Android
/// the only setting that actually achieves it is an <c>EditorInfo.inputType</c> carrying
/// <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD</c>. A page inside a <c>WebView</c> cannot reach that field —
/// Chromium derives the whole <c>EditorInfo</c> from the focused DOM node, and xterm.js already sets every
/// attribute the web platform offers (<c>autocorrect</c>, <c>autocapitalize</c>, <c>spellcheck</c>) to no
/// effect, because they are advisory and Gboard ignores them.
/// </para>
/// <para>
/// <b>Avalonia can reach it, on the version this repository already pins.</b>
/// <c>AndroidInputMethod.OnCreateInputConnection</c> in Avalonia.Android 12.1.1 reads:
/// <code>
/// if ((int)outAttrs.InputType == 1 &amp;&amp; options.ShowSuggestions == false)
/// outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
/// </code>
/// <c>0x80080</c> is <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS</c> — the exact
/// pair Termux's own <c>TerminalView</c> sets, and the reason typing in Termux feels like a terminal. The
/// guard is why the constructor leaves <c>ContentType</c>, <c>Multiline</c> and <c>AutoCapitalization</c>
/// alone: any of those would move <c>InputType</c> off <c>1</c> and the branch would never be taken.
/// <c>ShowSuggestions</c> is a <c>bool?</c> defaulting to <c>null</c>, so it must be set to <c>false</c>
/// rather than merely left.
/// </para>
/// <para>
/// <b>Two options are set, and the second one is not optional either.</b> The default
/// <c>ReturnKeyType</c> maps to <c>IME_ACTION_DONE</c> on a non-multiline field, which closed the keyboard
/// on every Enter — that is, on every command. <c>ReturnKeyType.Return</c> is <c>IME_ACTION_NONE</c> and
/// touches nothing else. The constructor carries the full argument, including why the obvious alternative
/// of <c>Multiline</c> cannot be used without giving the suggestion strip back.
/// </para>
/// <para>
/// <b>One pixel, and never drawn.</b> Focus in Avalonia has nothing to do with hit-testing, so the input
/// target does not need to cover the terminal — and must not, because the renderer is a native child view
/// composited above everything Avalonia paints, so a control stretched over it would be invisible, unable
/// to receive the touches the <c>WebView</c> consumes first, and in the way of the connect page that shares
/// this Panel when no session is open. A 1×1 control in the corner is focusable, costs one layout slot, and
/// is the whole of what an input target has to be. Taps on the terminal itself are noticed by
/// <see cref="Platform.RendererFocus"/> instead, which sees them where they actually land.
/// </para>
/// <para>
/// <b>Nothing is retained.</b> <see cref="TerminalImeClient"/> reports no surrounding text and no preedit,
/// because a terminal has no document for an input method to edit — the remote holds the line and the only
/// honest answer to "what is around the cursor" is "ask the host, which you cannot". Committed text is
/// turned into bytes and forwarded the instant it arrives.
/// </para>
/// </remarks>
internal sealed class TerminalInputSurface : Control
{
/// <summary>
/// Bytes the shell should receive, already encoded.
/// </summary>
/// <remarks>
/// Raised rather than sent here, because the Ctrl latch belongs to the accessory row and the session to
/// the view model. <c>TerminalScreen.SendAsync</c> owns both and applies the latch to anything one byte
/// long, which is exactly the set a latch should reach: a typed letter, yes; a pasted line or a cursor
/// key's escape sequence, no.
/// </remarks>
internal event EventHandler<TerminalInputEventArgs>? Input;
private readonly TerminalImeClient client;
/// <summary>
/// Whether the text event about to arrive has already been sent as a control byte.
/// </summary>
/// <remarks>
/// <para>
/// <b>Necessary because handling a key event does not stop the text that follows it.</b> Avalonia's
/// <c>AndroidKeyboardEventsHelper</c> raises the key event and then, independently, raises a text input
/// event for any key whose <c>UnicodeChar</c> is 32 or above — it reads the handled flag from the first
/// only to combine into its own return value, never to skip the second. So Ctrl-C on a hardware keyboard
/// produces both a <c>KeyDown</c> this class turns into <c>0x03</c> and a text input carrying a literal
/// <c>c</c>, and without this flag the shell receives an interrupt followed by a stray character.
/// </para>
/// <para>
/// A plain field rather than anything cleverer because the two events are raised one after the other in
/// the same dispatch, and it is cleared at the top of every <see cref="OnKeyDown"/> so a chord that
/// somehow produced no text cannot swallow the next thing typed.
/// </para>
/// <para>
/// The keys in <see cref="TryTranslate"/> need no such guard — Enter, Tab, Escape and Backspace all
/// carry a <c>UnicodeChar</c> below 32, so no text event is raised for them in the first place.
/// </para>
/// </remarks>
private bool controlByteAlreadySent;
public TerminalInputSurface()
{
client = new TerminalImeClient(this);
Focusable = true;
// Drawn nowhere and hit-tested never — see the remarks. The size is 1 rather than 0 only because a
// zero-area control is a shape no layout pass has a reason to keep.
Width = 1;
Height = 1;
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left;
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top;
IsHitTestVisible = false;
// The one line that fixes the keyboard. See the remarks for why almost nothing else is set.
TextInputOptions.SetShowSuggestions(this, false);
// ◆ AND THE ONE THAT KEEPS THE KEYBOARD UP WHEN A COMMAND IS RUN.
//
// Avalonia maps ReturnKeyType.Default, on a field that is not Multiline, to IME_ACTION_DONE — and
// Done means done: the keyboard performs the action and dismisses itself. Every Enter therefore
// closed the keyboard, which on a terminal is every command.
//
// Return is the member that maps to IME_ACTION_NONE, leaving the key as a plain newline with no
// action attached and no reason for the IME to hide. Its whole switch arm is
// `returnKeyType - 1 => 0 => 1073741824`, which is 0x40000000, IME_ACTION_NONE.
//
// Multiline=true reaches the same IME_ACTION_NONE through the default arm and is the wrong way to
// get there: it also ORs TYPE_TEXT_FLAG_MULTI_LINE into InputType, which moves InputType off 1 and
// silently fails the ShowSuggestions guard above. The two options cannot both be had that way — one
// trades the suggestion strip back for a working Enter. ReturnKeyType costs nothing and is the
// reason this file sets it rather than Multiline.
TextInputOptions.SetReturnKeyType(this, TextInputReturnKeyType.Return);
AddHandler(TextInputMethodClientRequestedEvent, OnTextInputMethodClientRequested);
}
/// <summary>
/// Takes focus and asks for the keyboard.
/// </summary>
/// <remarks>
/// <para>
/// The request is made even when focus was already here, which is the case that matters: the system back
/// gesture puts the keyboard away without moving focus, so the way back has to be an activation request
/// rather than a focus change that would be a no-op. <c>AndroidInputMethod</c> answers
/// <c>InputPaneActivationRequested</c> with <c>ShowSoftInput</c>, which is precisely this.
/// </para>
/// <para>
/// Focusing for the first time raises the keyboard on its own — <c>SetClient</c> calls
/// <c>RestartInput</c> and <c>ShowSoftInput</c> when it takes a non-null client — so the ordering here is
/// belt and braces rather than two mechanisms disagreeing.
/// </para>
/// </remarks>
internal void Acquire()
{
Focus();
client.RequestKeyboard();
}
private void OnTextInputMethodClientRequested(object? sender, TextInputMethodClientRequestedEventArgs e) =>
e.Client = client;
/// <remarks>
/// Everything the IME commits and everything a hardware keyboard types that is not in
/// <see cref="TryTranslate"/>'s table. UTF-8 because that is what the pty is told the session is, and
/// because a multi-byte character has to arrive as one write or a partial sequence reaches the remote.
/// </remarks>
protected override void OnTextInput(TextInputEventArgs e)
{
if (controlByteAlreadySent)
{
controlByteAlreadySent = false;
e.Handled = true;
return;
}
if (string.IsNullOrEmpty(e.Text))
{
return;
}
// Newlines arrive as carriage returns, whichever way they came. A keyboard with no editor action
// attached may commit "\n" as ordinary text rather than sending KEYCODE_ENTER, and a paste carries
// whatever the clipboard held — but a terminal's line terminator from a keyboard is CR, and a shell
// handed LF for Enter is a shell that looks like it ignored the key. Normalised rather than special
// -cased so that a pasted "\r\n" does not run the line twice.
var text = e.Text.ReplaceLineEndings("\r");
Input?.Invoke(this, new TerminalInputEventArgs(Encoding.UTF8.GetBytes(text)));
e.Handled = true;
base.OnTextInput(e);
}
/// <remarks>
/// <para>
/// Only the keys that have no text of their own are handled here; everything printable is left alone so
/// that it arrives once, through <see cref="OnTextInput"/>. Handling both would double every letter.
/// </para>
/// <para>
/// Ctrl with a letter is translated here rather than left to the latch, because a hardware keyboard can
/// hold a modifier and this is the only place that sees the chord.
/// </para>
/// </remarks>
protected override void OnKeyDown(KeyEventArgs e)
{
controlByteAlreadySent = false;
if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && TryControlChord(e.Key, out var chord))
{
Input?.Invoke(this, new TerminalInputEventArgs(chord));
// The letter's own text event is still coming; see the field's remarks.
controlByteAlreadySent = true;
e.Handled = true;
return;
}
if (TryTranslate(e.Key, out var bytes))
{
Input?.Invoke(this, new TerminalInputEventArgs(bytes));
e.Handled = true;
return;
}
base.OnKeyDown(e);
}
/// <remarks>
/// The ASCII rule and nothing cleverer, matching <c>TerminalScreen.SendAsync</c>'s latch: Ctrl-A through
/// Ctrl-Z are <c>0x01</c>–<c>0x1A</c>, which is the letter masked with <c>0x1F</c>.
/// </remarks>
private static bool TryControlChord(Key key, out byte[] bytes)
{
if (key is >= Key.A and <= Key.Z)
{
bytes = [(byte)((byte)('a' + (key - Key.A)) & 0x1F)];
return true;
}
bytes = [];
return false;
}
/// <summary>
/// The keys a terminal expects as escape sequences rather than as text.
/// </summary>
/// <remarks>
/// <para>
/// Cursor keys are written as CSI rather than SS3, which is the same choice — and the same reasoning —
/// as the accessory row's table in <c>TerminalScreen</c>: the difference between <c>CSI A</c> and
/// <c>SS3 A</c> is the difference between working in bash and not, and nothing here tracks the remote's
/// application-cursor mode.
/// </para>
/// <para>
/// <b><see cref="Key.Delete"/> is deliberately spent on backspace, and it is the one lossy choice in
/// this file.</b> Avalonia's Android input connection turns an IME's <c>deleteSurroundingText</c> into a
/// dispatched <c>KEYCODE_FORWARD_DEL</c> — see <c>TextEditBuffer.Remove</c> — so a soft keyboard that
/// deletes through the edit buffer rather than through a real <c>KEYCODE_DEL</c> arrives here as
/// <c>Delete</c>. Mapping it to <c>CSI 3 ~</c> would be correct for a hardware forward-delete and would
/// make backspace delete forwards on any keyboard taking the other route, which is the worse failure by
/// a wide margin: a phone has no forward-delete key to miss, and a broken backspace makes the terminal
/// unusable. Revisit if a hardware keyboard's Delete is ever worth more than this insurance.
/// </para>
/// </remarks>
private static bool TryTranslate(Key key, out byte[] bytes)
{
bytes = key switch
{
Key.Enter => [0x0D],
Key.Tab => [0x09],
Key.Escape => [0x1B],
Key.Back or Key.Delete => [0x7F],
Key.Up => [0x1B, (byte)'[', (byte)'A'],
Key.Down => [0x1B, (byte)'[', (byte)'B'],
Key.Right => [0x1B, (byte)'[', (byte)'C'],
Key.Left => [0x1B, (byte)'[', (byte)'D'],
Key.Home => [0x1B, (byte)'[', (byte)'H'],
Key.End => [0x1B, (byte)'[', (byte)'F'],
Key.Insert => [0x1B, (byte)'[', (byte)'2', (byte)'~'],
Key.PageUp => [0x1B, (byte)'[', (byte)'5', (byte)'~'],
Key.PageDown => [0x1B, (byte)'[', (byte)'6', (byte)'~'],
_ => [],
};
return bytes.Length > 0;
}
/// <summary>
/// What Android's input method is told about the text it is editing, which is that there is none.
/// </summary>
/// <remarks>
/// <para>
/// <c>SupportsSurroundingText</c> and <c>SupportsPreedit</c> are both false, and
/// <see cref="SurroundingText"/> is empty, because they are the truth: the line being edited lives on
/// the far side of an SSH channel and this client has never been told what is on it. Claiming otherwise
/// would invite the IME to reason about text it cannot see and to issue edits against positions that
/// mean nothing here.
/// </para>
/// <para>
/// <see cref="Selection"/> has to be settable because <c>TextEditBuffer</c> writes it before every
/// replacement, so it is stored and ignored rather than refused — a property that threw would take the
/// input connection down with it.
/// </para>
/// <para>
/// <see cref="CursorRectangle"/> is empty for the same reason the control is 1×1: it positions an IME's
/// composition popup, and a field that never composes has no popup to place.
/// </para>
/// </remarks>
private sealed class TerminalImeClient(Visual visual) : TextInputMethodClient
{
public override Visual TextViewVisual => visual;
public override bool SupportsPreedit => false;
public override bool SupportsSurroundingText => false;
public override string SurroundingText => string.Empty;
public override Rect CursorRectangle => default;
public override TextSelection Selection { get; set; }
/// <remarks>Exposed because <c>RaiseInputPaneActivationRequested</c> is protected on the base.</remarks>
internal void RequestKeyboard() => RaiseInputPaneActivationRequested();
}
}
@@ -114,7 +114,13 @@
<Panel>
<Grid RowDefinitions="Auto,*,Auto">
<!--
Four rows since the disconnect strip arrived. It is Auto and empty except while the selected tab's shell
has stopped, so an ordinary session pays nothing for it — and when it is up it takes its height off the
renderer rather than drawing over it, which on this head is not optional either: the renderer is a native
child view composited above everything Avalonia draws, so a banner over its rectangle would be invisible.
-->
<Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- ============ the bar ============ -->
<!--
@@ -177,11 +183,13 @@
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green only while there is a shell behind it, amber while one is being made; see
the same dot in PhoneShell, and Phone.axaml for why amber is not a rule broken.
Green only while there is a shell behind it, amber while one is being made, and
red where a connection broke rather than a shell exiting; see the same dot in
PhoneShell, and Phone.axaml for why neither of the last two is a rule broken.
-->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
Classes.connecting="{Binding IsConnecting}"
Classes.dropped="{Binding IsDropped}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
Text="{Binding Label}" />
@@ -333,7 +341,7 @@
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
TextTrimming="CharacterEllipsis" />
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
VerticalAlignment="Center" Text="{Binding Started}" />
@@ -421,6 +429,18 @@
ordinary way, through ShowScreenCommand, exactly as the rail and MORE reach it.
-->
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Left">
<!--
Only for a refusal, and it runs the same command the disconnect strip below the renderer does —
the vault's own connect path, back into this same tab. Worth more on a phone than on a desk: the
commonest refusal here is a mobile link that was not up yet, and the alternative is going back
to the hosts screen to find a machine whose name is already on this card.
-->
<Button Classes="row" MinHeight="44" Padding="14,0"
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
IsVisible="{Binding SelectedTab.IsFailed}">
<TextBlock Classes="label" FontSize="9" Text="TRY AGAIN" />
</Button>
<Button Classes="row" MinHeight="44" Padding="14,0"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
@@ -451,6 +471,29 @@
design's #171A26. See WebAssets/terminal.css.
-->
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
<!--
============ the keyboard ============
One pixel in the corner, drawn never, and it is where every character typed into a shell on this
phone arrives. ADR 0015 has the whole argument; the short of it is that a software keyboard over a
terminal has to be told to stop autocorrecting, and on Android the only setting that works is an
inputType on a native input connection — which a page inside a WebView cannot reach and an Avalonia
control can, on the version already pinned here.
Not stretched over the renderer, which would be the obvious shape and is wrong three times: the
WebView composites above everything Avalonia paints so it would be invisible, it would never see the
touches the WebView consumes first, and it would sit over the connect page that shares this Panel
whenever no session is open. Focus does not need area. Taps on the terminal are noticed by
RendererFocus, where they actually land.
Collapsed with the renderer, on the same binding and for a reason of its own: a focusable control is
a tab stop and a focus candidate wherever it lives, and this Panel is also the connect page when no
session is open. Left always-visible it would be a stray target on a screen with a real text box on
it, offering a keyboard configured for a shell to somebody typing a hostname.
-->
<views:TerminalInputSurface x:Name="Input"
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
<!-- ============ accessory keys ============ -->
@@ -462,70 +505,164 @@
-->
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
<!--
33 tall rather than 50, a third off it like the bar at the top, with the keys inside coming down from
38 to 30 for the reason the pills did: a row that shrank around its own contents would clip them.
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
The five pixels above it are a gap and not a border. The renderer is a native child view, so nothing
Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its own —
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
that has gone wrong rather than as two that are different things.
What this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
and it was wrong in two ways that compounded each other.
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
to be the first thing anybody noticed about this row.
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
which is a trade worth making right up until the control stops working, and 30 is past that.
── what the other clients do, because all of them agree ─────────────────────────────────────────────
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
second row — Termux ships two by default. That is not convention for its own sake: a key whose
position is fixed can be hit without looking, which is the entire point of a modifier row you use
while reading the output above it. A key that might have scrolled has to be found first.
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
ones that benefit, because the arrows were the keys living past the right edge.
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
Ctrl is not the better surface.
── the split ────────────────────────────────────────────────────────────────────────────────────────
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of
its own — and a terminal whose last line of output is flush against a row of grey keys reads as one
surface that has gone wrong rather than as two that are different things.
-->
<Border Grid.Row="2" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
<Grid ColumnDefinitions="*,Auto">
<!-- ============ what a terminal says once its shell has stopped ============ -->
<!--
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
its own, the screen goes off, and the tab that went quiet is discovered later with no idea whether the
shell was exited or cut off. So it names which, prints the sentence that came with it, and offers the
one useful answer.
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
VerticalAlignment="Center" />
</ScrollViewer>
Two lines rather than the desktop's one, because 360dp cannot hold a word, a sentence and two buttons
across. The word and the buttons share the top line — the buttons are where a thumb already is — and
the sentence wraps underneath at two lines maximum, which is enough for anything sshd or a socket
error actually says and short of a strip that eats the terminal.
RECONNECT is disabled rather than hidden when there is nothing to dial: a machine typed in by hand has
no keychain entry to redial, and a control visibly refusing answers "why not" better than an absent one.
See MainWindowViewModel.CanReconnectSelectedTab.
-->
<Border Grid.Row="2" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="16,8"
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Classes="dot" Classes.dropped="{Binding IsSessionDropped}" Width="7" Height="7"
VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="9" Text="{Binding SessionEndedText}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Classes="row" MinHeight="40" Padding="12,0"
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}">
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
</Button>
<Button Classes="row" MinHeight="40" Padding="12,0"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
</Button>
</StackPanel>
</Grid>
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
Text="{Binding SessionEndedDetail}"
IsVisible="{Binding SessionEndedDetail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</Border>
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
<StackPanel Spacing="4">
<!--
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
beside them.
They used to have a row of their own above the terminal, on the argument that the accessory row
scrolls and these two must never be off-screen — a terminal too small to read is exactly the state
in which hunting for the control that fixes it is worst. That argument is answered rather than
abandoned: outside the ScrollViewer they cannot scroll away, and the row they had costs 36 pixels
on a surface this change exists to give back.
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
has no keyboard to press it with, which is why the two heads differ here and nowhere else in this
screen.
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
nothing reads as the terminal having stopped responding, which is the one thing this screen must
never look like.
◆ Focusable="False", for the reason the accessory keys carry in code — see
TerminalScreen.axaml.cs. Every button on this row sits over a terminal somebody is typing into,
and an ordinary one takes Avalonia's focus off the NativeWebView on tap, which makes the package
resign the page's focus. Making the text smaller must not cost the keyboard.
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
between them instead of taking what their labels happen to need — "ctrl" is four characters and
"|" is one, and on a row of keys those two want the same target, not proportional ones.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
VerticalAlignment="Center">
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
VerticalAlignment="Center" />
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" Focusable="False"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A−" />
</Button>
<!--
The cursor keys, and the two text-size buttons riding at the end.
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" Focusable="False"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
</StackPanel>
</Grid>
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
them every time the row's contents changed.
-->
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
<!--
The text-size buttons.
They used to have a row of their own above the terminal, on the argument that the accessory row
scrolls and these two must never be off-screen — a terminal too small to read is exactly the
state in which hunting for the control that fixes it is worst. Nothing on this keypad scrolls
any more, so that argument is now satisfied by construction rather than by placement.
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
has no keyboard to press it with, which is why the two heads differ here and nowhere else in
this screen.
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
nothing reads as the terminal having stopped responding, which is the one thing this screen must
never look like.
◆ Focusable="False", for the reason the accessory keys carry in code — see
TerminalScreen.axaml.cs. Every button on this keypad sits over a terminal somebody is typing
into, and an ordinary one takes Avalonia's focus off the input surface on tap. Making the text
smaller must not cost the keyboard.
-->
<!-- No Spacing: Button.hotkey carries a 2-pixel side margin, so the gaps here match the
UniformGrid cells' without being counted twice. -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="8,0,0,0">
<Border Width="1" Height="20" Background="{StaticResource Border}" Margin="0,0,4,0"
VerticalAlignment="Center" />
<Button Classes="hotkey" Width="44" Focusable="False"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A−" />
</Button>
<Button Classes="hotkey" Width="44" Focusable="False"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</Grid>
@@ -1,12 +1,15 @@
using System.ComponentModel;
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Threading;
using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Shell.ViewModels;
@@ -37,7 +40,14 @@ internal sealed partial class TerminalScreen : UserControl
/// keys to fit 360dp produces ten targets too small to hit.
/// </para>
/// </remarks>
private static readonly (string Label, byte[] Bytes, bool Latches)[] Keys =
/// <summary>The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.</summary>
/// <remarks>
/// Seven, which is what the row divides between — see the keypad's remark in TerminalScreen.axaml for
/// why the set is fixed and flexed rather than scrollable. Adding an eighth is a matter of adding it
/// here and changing the <c>Columns</c> on the UniformGrid; the two numbers have to agree, and the row
/// simply gets narrower keys.
/// </remarks>
private static readonly (string Label, byte[] Bytes, bool Latches)[] ModifierKeys =
[
("esc", [0x1B], false),
("tab", [0x09], false),
@@ -46,12 +56,31 @@ internal sealed partial class TerminalScreen : UserControl
("-", [(byte)'-'], false),
("~", [(byte)'~'], false),
("/", [(byte)'/'], false),
];
// The cursor keys, as the ANSI sequences a PTY expects. Written out rather than composed, because
// the difference between CSI A and SS3 A is the difference between working in bash and not.
("↑", [0x1B, (byte)'[', (byte)'A'], false),
("↓", [0x1B, (byte)'[', (byte)'B'], false),
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
/// <remarks>
/// <para>
/// The ANSI sequences a PTY expects, written out rather than composed, because the difference between
/// CSI A and SS3 A is the difference between working in bash and not.
/// </para>
/// <para>
/// ◆ <b>Left, down, up, right</b> — the order every other client on the platform puts them in, and not
/// the order they were in here. On one flat row the pairs have to read as pairs: horizontal at the ends
/// and vertical in the middle means the two keys that undo each other are always neighbours, and a
/// thumb learns the row's shape rather than its labels. Up-down-left-right is the order they are named
/// in, which is a different thing from the order they are used in.
/// </para>
/// <para>
/// These four were the keys that used to live past the right-hand edge of the scroller — the most
/// pressed keys on the keypad, and the ones you had to go looking for.
/// </para>
/// </remarks>
private static readonly (string Label, byte[] Bytes, bool Latches)[] CursorKeys =
[
("←", [0x1B, (byte)'[', (byte)'D'], false),
("↓", [0x1B, (byte)'[', (byte)'B'], false),
("↑", [0x1B, (byte)'[', (byte)'A'], false),
("→", [0x1B, (byte)'[', (byte)'C'], false),
];
@@ -67,26 +96,119 @@ internal sealed partial class TerminalScreen : UserControl
private Button? controlKey;
private readonly TerminalInputSurface input;
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
private MainWindowViewModel? subscribed;
/// <summary>
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
/// </summary>
/// <remarks>
/// The keyboard is raised on the transition into the terminal and not on every notification of it.
/// <c>IsTerminalShowing</c> is a computed property re-raised by several other setters, so acting on the
/// notification rather than the edge would call <c>ShowSoftInput</c> repeatedly for one arrival.
/// </remarks>
private bool terminalWasShowing;
public TerminalScreen()
{
AvaloniaXamlLoader.Load(this);
BuildAccessoryRow();
input = this.FindControl<TerminalInputSurface>("Input")!;
input.Input += (_, e) => SendAsync(e.Bytes);
var renderer = this.FindControl<NativeWebView>("Renderer")!;
renderer.EnvironmentRequested += OnRendererEnvironmentRequested;
renderer.EnvironmentRequested += (sender, e) =>
{
OnRendererEnvironmentRequested(sender, e);
// The precise moment the platform WebView is being built, which is the earliest one at which
// there is a native view to take the keyboard away from — and the one that fires again if
// Android reclaims the renderer and the page comes back. Posted rather than called, because the
// child is not in the hierarchy yet while its environment is still being asked for.
Dispatcher.UIThread.Post(AdoptRenderer, DispatcherPriority.Loaded);
};
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
DataContextChanged += (_, _) =>
{
if (subscribed is { } previous)
{
previous.PropertyChanged -= OnShellPropertyChanged;
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
subscribed = null;
}
if (DataContext is MainWindowViewModel shell)
{
renderer.Source = shell.TerminalPageUrl;
shell.PropertyChanged += OnShellPropertyChanged;
// The snippets screen's "TYPE INTO {tab}" lands here as well as on the desktop. It types at
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
// they look. See MainWindowViewModel.TerminalFocusRequested.
shell.TerminalFocusRequested += OnTerminalFocusRequested;
subscribed = shell;
terminalWasShowing = shell.IsTerminalShowing;
}
};
}
/// <summary>
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
/// </summary>
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is not nameof(MainWindowViewModel.IsTerminalShowing) ||
sender is not MainWindowViewModel shell)
{
return;
}
var showing = shell.IsTerminalShowing;
if (showing == terminalWasShowing)
{
return;
}
terminalWasShowing = showing;
if (!showing)
{
return;
}
// Posted to Loaded so the renderer's own native child, which becomes visible on this same change,
// exists by the time AdoptRenderer goes looking for it. Idempotent with the EnvironmentRequested
// path above: whichever runs first configures the view and the other finds it already done.
Dispatcher.UIThread.Post(
() =>
{
AdoptRenderer();
input.Acquire();
},
DispatcherPriority.Loaded);
}
private void OnTerminalFocusRequested(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(input.Acquire, DispatcherPriority.Loaded);
/// <summary>
/// Takes the keyboard off the renderer and puts the taps on it to use.
/// </summary>
/// <remarks>
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
/// the tap has to be reported rather than left to the page.
/// </remarks>
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
/// <summary>
/// Puts the keyboard away on the way to the connect menu.
/// </summary>
@@ -137,18 +259,26 @@ internal sealed partial class TerminalScreen : UserControl
private void BuildAccessoryRow()
{
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
// ── nothing to hand back any more ──────────────────────────────────────────────────────────────
// This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
// the platform requests Android's own focus for Avalonia's view after dispatching each handled
// touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
// TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
// the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
// stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
// length of the press; and the Return is posted past the current dispatch, so its ordering against
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
}
foreach (var (label, bytes, latches) in Keys)
/// <summary>Puts one table's worth of keys into one row of the keypad.</summary>
/// <remarks>
/// The rows differ in what they hold and in nothing else — same control, same class, same wiring — so
/// they are filled by one method rather than by two that would drift.
/// </remarks>
private void Fill(UniformGrid row, (string Label, byte[] Bytes, bool Latches)[] keys)
{
foreach (var (label, bytes, latches) in keys)
{
var key = CreateKey(label);
@@ -171,49 +301,31 @@ internal sealed partial class TerminalScreen : UserControl
private static Button CreateKey(string label) =>
new()
{
Content = new TextBlock
{
Text = label,
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
Content = label,
// 44 wide, and that is the number that matters: the design draws these flexed across the
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
// costs nothing a width does: the keys are a single row with the terminal above and the
// system's gesture bar below, so there is no neighbour a short press can land on instead.
MinWidth = 44,
Height = 30,
Padding = new Thickness(10, 0),
CornerRadius = new CornerRadius(9),
Background = Palette("Panel"),
BorderBrush = Palette("BorderMid"),
BorderThickness = new Thickness(1),
Foreground = Palette("TextDim"),
HorizontalContentAlignment = HorizontalAlignment.Center,
// ◆ Every dimension and brush this used to set by hand is now Button.hotkey in Phone.axaml,
// and the move is what lets the row have a pressed state at all: a style can say :pressed and
// an assignment cannot. It also takes this file out of the business of naming the palette's
// brushes, which is how a keypad ends up with a grey nothing else uses.
//
// Content is the string rather than a TextBlock built here for the same reason — the class
// carries the font and the alignment, so a wrapper element would only be somewhere for them to
// disagree.
Classes = { "hotkey" },
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
//
// These keys are an extension of the keyboard, not a place the keyboard should go. As
// ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView
// — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing
// Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next
// thing typed on a hardware keyboard went nowhere, and the row went on working because its
// buttons are pressed rather than typed into, which is what makes it look like the terminal
// had died instead.
// ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
// terminal's input target is — and a modifier row that dismisses the keyboard every time it
// is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
// the focused element never changes: TerminalInputSurface is still it, so the input
// connection is never torn down and nothing has to be handed back.
//
// Focusable=false is what a toolbar button is, and it means the focused element never
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
//
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
// decides about its element — taking the keyboard's input connection off the terminal and
// swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
// that half back; see TerminalFocus for the whole story, including why the handing back has
// to be posted rather than done inline.
// This used to be only half a fix, because the target was the NativeWebView and Android keeps
// a focus of its own that the platform hands to Avalonia's view after every touch it
// dispatches. That half is gone rather than countered: under ADR 0015 the input target is an
// Avalonia control, so the focus Android moves is already where the keyboard should be.
Focusable = false,
};
@@ -228,18 +340,12 @@ internal sealed partial class TerminalScreen : UserControl
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
// ^L to a database prompt believing they typed an l.
controlKey.Background = Palette(controlLatched ? "Active" : "Panel");
controlKey.Foreground = Palette(controlLatched ? "AccentText" : "TextDim");
//
// A class rather than two brush assignments, so what "latched" looks like is stated once in
// Phone.axaml beside what "pressed" looks like — which is where somebody changing either will be.
controlKey.Classes.Set("latched", controlLatched);
}
/// <summary>One brush from <c>Theme/Palette.axaml</c>, by key.</summary>
/// <remarks>
/// The accessory row is built in code because its keys come from a table, so its colours cannot be set in
/// XAML with the rest of the screen's. Resolving them by name is the next best thing: a row that named
/// its own blues is how the palette ends up with a fifth surface nobody meant to add.
/// </remarks>
private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush;
/// <remarks>
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
/// 0x1A, which is letter &amp; 0x1F. Applied only to letters, because Ctrl with an arrow key is a
@@ -220,7 +220,7 @@
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
<!--
◆ The one fact on this row that decides whether the button at the foot of the screen
+31 -1
View File
@@ -259,6 +259,22 @@ public interface ISyncApi
CancellationToken cancellationToken);
}
/// <summary>Asks the server for leave to relay one connection to one host.</summary>
/// <remarks>
/// The request names a host and nothing else. Where it is, and whether this caller may reach it, is the
/// server's decision, made from its own copy of the host's relay address — which is what keeps the relay
/// from being an open proxy that a client points wherever it likes. See ADR 0004.
/// </remarks>
public interface IRelayApi
{
/// <summary>Issues a single-use ticket, good for thirty seconds, for one relay socket to this host.</summary>
/// <exception cref="DodoSshApiException">
/// The server refused: <c>relay-unavailable</c>, <c>relay-target-rejected</c> or
/// <c>relay-limit-reached</c>, each with its own remedy.
/// </exception>
Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken);
}
/// <summary>
/// The typed client for one DodoSSH server.
/// </summary>
@@ -275,7 +291,7 @@ public interface ISyncApi
/// </para>
/// </remarks>
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi, IRelayApi
{
private const string MetaPath = "/api/v1/meta";
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
@@ -310,6 +326,20 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
DodoSshJsonContext.Default.DodoSshConfiguration,
cancellationToken);
/// <inheritdoc />
/// <remarks>
/// Never cached, and never fetched ahead of need: a ticket is spent by the socket it opens and is
/// worthless thirty seconds after issue, so the only right moment to ask for one is the connect that
/// will use it.
/// </remarks>
public Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
RelayProtocol.TicketsPath,
JsonContent.Create(new RelayTicketRequest(hostId, PortForwardId: null), DodoSshJsonContext.Default.RelayTicketRequest),
DodoSshJsonContext.Default.RelayTicketResponse,
cancellationToken);
/// <summary>
/// Reads the caller's profile, unlock material and reachable vaults.
/// </summary>
+11
View File
@@ -1282,6 +1282,17 @@
<Setter Property="Fill" Value="{StaticResource Warn}" />
</Style>
<!--
Red, and it is the fourth state this dot has rather than a recolouring of the grey one. Grey is a
shell that ended — somebody typed `exit`, and nothing is wrong with the machine. This is a connection
that went out from under a session that had not finished, which is the one worth looking at: the two
used to be the same dot, so a tab that had been cut off and a tab somebody had closed down properly
were indistinguishable in the strip. See TerminalTabViewModel.EndedSummary.
-->
<Style Selector="Ellipse.dot.dropped">
<Setter Property="Fill" Value="{StaticResource Danger}" />
</Style>
<!--
The accent strip a selected row carries, drawn by the row template rather than by the item, because the
item's presenter is the thing the theme keeps repainting.
+228 -14
View File
@@ -3,6 +3,7 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Auth;
@@ -33,12 +34,77 @@ internal sealed partial class DodoSshApp : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
Compose(desktop);
ShowSomethingWhileComposing(desktop);
}
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// Puts a window on screen first, and composes the application behind it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The splash <em>is</em> the main window for a moment, which is what makes this work at all.</b> The
/// desktop lifetime shows whatever <c>MainWindow</c> holds once this method returns and then runs the
/// message loop; a window created here and merely shown would be a window with no lifetime attached, and
/// with <c>ShutdownMode.OnLastWindowClose</c> the first close of anything would end the process. Handing
/// the property over is one assignment, and Avalonia reads it lazily.
/// </para>
/// <para>
/// <b>And composition is posted rather than called.</b> It is synchronous and takes long enough to be
/// worth a splash in the first place — which is the whole argument for this method — so running it here
/// would block the very dispatcher turn that has to paint the thing. Background priority puts it behind
/// rendering, which is what makes this a painted window rather than merely a shown one.
/// </para>
/// </remarks>
private static void ShowSomethingWhileComposing(IClassicDesktopStyleApplicationLifetime desktop)
{
var splash = new SplashWindow();
desktop.MainWindow = splash;
// Fully qualified, and it has to be: Application inherits an instance Dispatcher property from
// AvaloniaObject, member lookup beats the namespace, and a static method here cannot reach an
// instance property — so the unqualified name is a compile error rather than the type. Same below.
Avalonia.Threading.Dispatcher.UIThread.Post(
() => ComposeBehind(desktop, splash),
DispatcherPriority.Background);
}
/// <remarks>
/// <para>
/// The order at the end is not interchangeable. <c>ShutdownMode</c> is <c>OnLastWindowClose</c>, so the
/// real window is shown while the splash is still open and the splash is closed afterwards; closing the
/// only window there is would end the process instead of handing it over.
/// </para>
/// <para>
/// <b>A failure is caught rather than left to the dispatcher, and the splash is exactly why.</b> Before
/// this method existed a composition that threw took the process with it and left nothing behind. Now
/// there is something to leave behind — so an exception escaping a posted callback would abandon a window
/// saying "Starting…" over a process that had stopped, which is worse than the crash it replaced. What
/// arrives instead is the sentence and a way to close it.
/// </para>
/// </remarks>
private static void ComposeBehind(
IClassicDesktopStyleApplicationLifetime desktop,
SplashWindow splash)
{
try
{
Compose(desktop);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
splash.CouldNotStart(exception.Message);
return;
}
desktop.MainWindow?.Show();
splash.Close();
}
/// <remarks>
/// <para>
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
@@ -72,6 +138,17 @@ internal sealed partial class DodoSshApp : Application
}
};
/// <summary>Reads this machine's clipboard.</summary>
/// <remarks>
/// The other direction, looked up per call for the same reason and with the same fall-through. One
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
/// </remarks>
private static Func<Task<string?>> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) =>
async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard }
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
: null;
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
/// <remarks>
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
@@ -113,14 +190,75 @@ internal sealed partial class DodoSshApp : Application
var browser = new SystemBrowserLauncher();
// Both chosen once, here, because each is a property of the machine rather than of any session. A
// computer with a usable TPM gets the store that keeps a device key behind a Windows consent prompt;
// anything else gets one that reports itself unavailable, so unlock keeps asking for the passphrase
// Windows box with a usable TPM gets a consent prompt, a Mac with a Secure Enclave gets Touch ID, a
// Linux box whose TPM this user may open gets a PIN the TPM checks — see PinPrompt — and anything
// else gets a store that reports itself unavailable, so unlock keeps asking for the passphrase
// (ADR 0007). The update channel answers the same shape of question about how this copy was
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0013.
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths, PinPrompt(desktop));
var updates = UpdateChannels.ForThisMachine();
var viewModel = new MainWindowViewModel(
var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections,
browser, updates);
desktop.MainWindow = new MainWindow { DataContext = viewModel };
// The other half of Program's claim on this profile: a second launch was refused, and this is what
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
var others = SingleInstance.Listen(
paths,
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
// Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why
// discarding the task here is safe rather than merely convenient.
_ = viewModel.StartAsync(CancellationToken.None);
WireShutdown(desktop, viewModel, workspace, caches, others);
}
/// <summary>
/// Raises the window somebody has just asked for by launching the application again.
/// </summary>
/// <remarks>
/// Un-minimised first, because activating a minimised window leaves it minimised and the launch would
/// look ignored. <c>Activate</c> is a request rather than a guarantee — Windows refuses the foreground to
/// a process that did not receive the input, and what happens then is a flashing taskbar button, which is
/// still an answer. That is the platform's own rule about focus stealing and not a thing to work around.
/// </remarks>
private static void BringToFront(Window? window)
{
if (window is null)
{
return;
}
if (window.WindowState is WindowState.Minimized)
{
window.WindowState = WindowState.Normal;
}
window.Show();
window.Activate();
}
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
/// <remarks>
/// Split out of <see cref="Compose"/> for length rather than for reuse — it has one caller, and the
/// method it came out of is at the limit the analyzers set.
/// </remarks>
private static MainWindowViewModel BuildShell(
IClassicDesktopStyleApplicationLifetime desktop,
ClientPaths paths,
ClientCacheFactory caches,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
IDeviceKeyStore deviceKeys,
SshNetConnectionFactory connections,
SystemBrowserLauncher browser,
IUpdateChannel updates) =>
new(
paths,
caches,
workspace,
@@ -140,18 +278,88 @@ internal sealed partial class DodoSshApp : Application
.ConfigureAwait(false),
copyToClipboard: ClipboardWriter(desktop),
updates: updates);
readClipboard: ClipboardReader(desktop),
updates: updates,
openInEditor: OpenInDefaultEditorAsync,
watchFile: WatchFile);
desktop.MainWindow = new MainWindow { DataContext = viewModel };
/// <summary>
/// Hands a local file to whatever this machine opens that kind of file with.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
/// Services binding depending on which desktop this is, and watching a file is a platform object with
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
private static Task OpenInDefaultEditorAsync(string path)
{
using var opened = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
// Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why
// discarding the task here is safe rather than merely convenient.
_ = viewModel.StartAsync(CancellationToken.None);
WireShutdown(desktop, viewModel, workspace, caches);
return Task.CompletedTask;
}
/// <summary>
/// Watches one file for saves, until the handle is disposed.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The directory is watched and the name filtered, which is not the same as watching the file.</b>
/// A great many editors do not write in place: they write a temporary file beside the original and
/// rename it over the top, which to a watcher pointed at an inode is the file disappearing. Watching the
/// containing directory for that name catches the rename as a <c>Created</c>, which is what makes this
/// work with the editors people actually use.
/// </para>
/// <para>
/// <b>Every kind of change is one callback, and the shell debounces nothing.</b> One save can raise two
/// or three events; what the shell does with each is stat the host and compare, which is a round trip
/// but a small one, and the alternative — a timer here — is a delay before somebody's edit reaches the
/// server for the sake of saving it.
/// </para>
/// </remarks>
private static FileSystemWatcher WatchFile(string path, Action onChanged)
{
var watcher = new FileSystemWatcher(
Path.GetDirectoryName(path)!, Path.GetFileName(path))
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName,
};
watcher.Changed += (_, _) => onChanged();
watcher.Created += (_, _) => onChanged();
watcher.Renamed += (_, _) => onChanged();
watcher.EnableRaisingEvents = true;
return watcher;
}
/// <summary>How the Linux device key store asks for a PIN, and the only thing that ever calls it.</summary>
/// <remarks>
/// The lifetime is captured rather than the window, so this can be composed before there is one —
/// composition runs first, and <c>DevicePinWindow.AskAsync</c> answers null when there is nothing to
/// sit over. Windows and macOS never reach it: on those platforms the consent dialog is the operating
/// system's and this process is not in the conversation. See ADR 0007.
/// </remarks>
private static Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> PinPrompt(
IClassicDesktopStyleApplicationLifetime desktop) =>
(purpose, cancellationToken) =>
DevicePinWindow.AskAsync(() => desktop.MainWindow, purpose, cancellationToken);
/// <remarks>
/// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and
/// blocking the UI thread on their disposal is how an application comes to take several seconds to close —
@@ -161,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
IClassicDesktopStyleApplicationLifetime desktop,
MainWindowViewModel viewModel,
TerminalWorkspace workspace,
ClientCacheFactory caches)
ClientCacheFactory caches,
IAsyncDisposable others)
{
var shuttingDown = false;
@@ -180,6 +389,11 @@ internal sealed partial class DodoSshApp : Application
await viewModel.DisposeAsync().ConfigureAwait(true);
await workspace.DisposeAsync().ConfigureAwait(true);
// Awaited rather than dropped, so the pipe name is free before this process is. It matters for
// one caller and matters a lot there: an update relaunches this application, and a successor
// that found the name still taken would be a second instance refusing itself.
await others.DisposeAsync().ConfigureAwait(true);
caches.Dispose();
desktop.Shutdown();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

@@ -225,3 +225,37 @@ $icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
$icns.Dispose()
Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)"
# ---- dodossh-<size>.png, for the Linux desktop entry ---------------------------------------------
#
# The third platform, and the only one that wants the raster loose rather than in a container. A
# freedesktop icon theme is a directory tree — hicolor/48x48/apps/dodossh.png and friends — and the
# RPM built by scripts/ci-linux-rpm.sh installs one file per size into it.
#
# Written here for the reason this whole file exists: one geometry, regenerated in one command. The
# alternative was to have the packaging script pull the 256px frame out of dodossh.ico at build time,
# which works — the .ico's entries really are PNGs — and puts a second place that has to know how an
# ICO directory is laid out. It also silently ties the Linux icon to whatever sizes Windows happens
# to want.
#
# ◆ THE TILE BLEEDS TO THE EDGE HERE, AS IT DOES ON WINDOWS.
#
# tileFraction stays 1.0. The 0.8047 inset above is Apple's icon grid and nothing else — GNOME, KDE
# and every panel that reads hicolor draw the icon at the size they asked for, with no shadow and no
# magnification to leave air for. An inset icon would simply be the small one in the launcher.
#
# ◆ AND THE SIZES ARE NOT A ROUND NUMBER OF THEM.
#
# 48 is the size a GNOME dash and most panels ask for, 128 and 256 are what a large-icon file manager
# and GNOME Software use, and 64 is between them because the 48 upscaled to it is visibly soft. There
# is deliberately nothing below 48: the hairline floor in New-MarkPng keeps a 16px mark legible, but
# no Linux desktop asks this theme for one — window titlebars there draw no icon at all.
$linuxSizes = @(48, 64, 128, 256)
foreach ($size in $linuxSizes)
{
[byte[]]$png = New-MarkPng $size
$pngTarget = Join-Path $PSScriptRoot "dodossh-$size.png"
[System.IO.File]::WriteAllBytes($pngTarget, $png)
Write-Output "Wrote $pngTarget ($((Get-Item $pngTarget).Length) bytes)"
}
@@ -151,6 +151,14 @@
interface declared in DodoSSH.Client.Session.
-->
<PackageReference Include="Velopack" />
<!--
Here for the same reason and with the same boundary as Velopack above: LinuxDeviceKeyStore is a
Linux-only implementation of an interface declared in DodoSSH.Client.Session, and it lives in
Platform/ beside the Windows and macOS stores. The Android head has its own keystore and must
never see this one.
-->
<PackageReference Include="Microsoft.TSS" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,491 @@
using System.Buffers.Binary;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.Session;
using Tpm2Lib;
namespace DodoSSH.Client.App.Platform;
/// <summary>Why the store is asking for a PIN.</summary>
/// <remarks>
/// The two cases need different words in front of a person — one is choosing a PIN and the other is
/// entering one — and the store is the only thing that knows which is happening. Passing an enum rather
/// than two delegates keeps the prompt a single seam.
/// </remarks>
public enum DevicePinPurpose
{
/// <summary>A PIN is being chosen, as part of registering this machine.</summary>
Choosing,
/// <summary>A PIN is being asked for, to unlock.</summary>
Entering,
}
/// <summary>
/// Keeps the device key sealed inside the TPM, released only against a PIN the TPM itself checks.
/// </summary>
/// <remarks>
/// <para>
/// The Linux answer to <see cref="WindowsDeviceKeyStore"/> and <c>MacDeviceKeyStore</c>, and ADR 0007's
/// bar is the one it has to clear: <b>the enforcement must be the platform's rather than this process's.</b>
/// A store that drew its own prompt over a file it could read anyway would be the mistake that ADR's
/// 2026-07-30 amendment exists to record — malware running as the user skips the prompt and reads the file.
/// </para>
/// <para>
/// <b>Here the TPM holds the key and the TPM checks the PIN.</b> The 32-byte device key is sealed into a
/// keyedhash object whose <c>userAuth</c> is derived from the PIN, so unsealing is a command the TPM
/// refuses without it. The blob on disk is encrypted to the TPM's storage hierarchy and is useless on any
/// other machine, and the PIN is never compared by anything in this assembly. Malware running as the user
/// can ask; it cannot answer.
/// </para>
/// <para>
/// <b>And the TPM counts the failures, which is what makes a short PIN safe.</b> The sealed object
/// deliberately does not carry <c>NoDA</c>, so every wrong answer advances the dictionary-attack counter
/// and the TPM stops answering at all once it trips. Measured against a software TPM: four wrong PINs and
/// it refuses the right one too, until the lockout recovers. That is the property a four-digit secret has
/// on this platform and cannot have in a file.
/// </para>
/// <para>
/// ◆ <b>What this does NOT match, stated because the difference is real and invisible.</b> Windows Hello
/// and Touch ID raise their prompt from the platform — a secure desktop on one, a system sheet on the
/// other — so the process asking never sees the secret. Linux has no equivalent to borrow, so the PIN is
/// typed into a window this application draws, and a sufficiently determined attacker already running as
/// the user could draw a convincing copy of it. The mitigation is that they could do the same for the
/// passphrase screen, which is a much larger prize, so this is not a capability the feature creates. It is
/// still a weaker guarantee than the other two desktops have and ADR 0007 says so rather than implying
/// parity.
/// </para>
/// <para>
/// <b>Availability still depends on reaching the device, and the package arranges that rather than asking
/// the user to.</b> Fedora leaves <c>/dev/tpmrm0</c> as <c>root:tss</c> mode 0660, so without help this
/// process could not open it on an ordinary desktop account. The RPM installs
/// <c>build/linux/71-dodossh-tpm.rules</c>, which tags the device <c>uaccess</c> — logind then grants an
/// ACL to whoever is logged in at the machine, and takes it away when they log out. Not over SSH and not
/// to a user on an inactive VT.
/// <para>
/// So a build run from a checkout, or one on a machine where an administrator has masked that rule, still
/// finds the device unreadable and reports unavailable — which keeps the passphrase path exactly as it was.
/// The rule carries its own reasoning, including the lockout denial-of-service it accepts; see ADR 0007
/// decision 4.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
public sealed class LinuxDeviceKeyStore : IDeviceKeyStore
{
/// <summary>What the blob on disk starts with, so a file from elsewhere is refused rather than parsed.</summary>
private static readonly byte[] Magic = "DSHTPMv1"u8.ToArray();
/// <summary>
/// Domain separation for the PIN, so the value handed to the TPM is this application's and not the
/// user's PIN as typed.
/// </summary>
/// <remarks>
/// The TPM compares an authValue of at most one digest in length, so something has to reduce a typed
/// string to bytes; hashing with a label rather than truncating means a PIN reused elsewhere does not
/// arrive at the TPM as the same bytes it does there. It buys nothing against an attacker who has the
/// blob and the machine — they have this constant too — and it costs one line.
/// </remarks>
private const string PinLabel = "dodossh/devicekey/pin/v1";
private readonly ClientPaths paths;
private readonly Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin;
private readonly Func<Tpm2Device> openDevice;
/// <summary>Creates a store against this machine's TPM.</summary>
/// <param name="paths">Where the sealed blob lives.</param>
/// <param name="askForPin">
/// Shows the PIN prompt and answers with what was typed, or null if the user declined. Declining is
/// ordinary — see <see cref="IDeviceKeyStore"/> — and lands on the passphrase.
/// </param>
public LinuxDeviceKeyStore(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
: this(paths, askForPin, static () => new LinuxTpmDevice())
{
}
/// <summary>Creates a store against a named TPM, which is how the tests reach a simulator.</summary>
/// <remarks>
/// The seam exists because this class is testable in a way the other two stores are not, and that is
/// worth keeping. Windows and macOS put the prompt inside the platform, so a test that exercised
/// sealing would block forever on a dialog — <c>WindowsDeviceKeyStoreTests</c> records the ten minutes
/// that took to discover. Here the prompt is a delegate and the TPM is a device, so both can be
/// supplied and the whole path runs unattended.
/// </remarks>
internal LinuxDeviceKeyStore(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin,
Func<Tpm2Device> openDevice)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(askForPin);
ArgumentNullException.ThrowIfNull(openDevice);
this.paths = paths;
this.askForPin = askForPin;
this.openDevice = openDevice;
}
/// <summary>
/// The parent every sealed object hangs off, as a template rather than as a stored handle.
/// </summary>
/// <remarks>
/// <para>
/// <b>Recreated from a fixed template on every use, and not made persistent.</b> A persistent handle
/// would occupy one of the TPM's small number of NV slots forever, would have to be cleaned up by
/// <see cref="ForgetAsync"/>, and would collide with whatever else on the machine had chosen the same
/// index. Deriving it costs a command and leaves nothing behind.
/// </para>
/// <para>
/// <b>ECC P-256 rather than RSA-2048, and that is a user-visible choice.</b> The two are equally
/// standard as storage roots. Creating an RSA primary on a discrete TPM takes seconds — the key is
/// generated, not derived — and this runs on the unlock path, so it would show up as the application
/// hanging every time somebody opened their vault. The prototype this was built from used RSA and that
/// is exactly what it was changed for.
/// </para>
/// <para>
/// <c>NoDA</c> is set <em>here</em> and deliberately not on the sealed object. The parent has no
/// authValue to guess, so counting failures against it would only give an attacker a way to lock the
/// machine's TPM out by asking for something they were never going to get.
/// </para>
/// </remarks>
private static TpmPublic PrimaryTemplate => new(
TpmAlgId.Sha256,
ObjectAttr.Restricted | ObjectAttr.Decrypt | ObjectAttr.FixedTPM | ObjectAttr.FixedParent
| ObjectAttr.SensitiveDataOrigin | ObjectAttr.UserWithAuth | ObjectAttr.NoDA,
null,
new EccParms(
new SymDefObject(TpmAlgId.Aes, 128, TpmAlgId.Cfb),
new NullAsymScheme(),
EccCurve.TpmEccNistP256,
new NullKdfScheme()),
new EccPoint());
/// <summary>
/// The sealed object: bytes the TPM holds, released against an authValue the TPM checks.
/// </summary>
/// <remarks>
/// Keyedhash with a null scheme is TPM 2.0's "just keep these bytes" object. <c>UserWithAuth</c> is
/// what makes the authValue the thing that opens it, and the deliberate <em>absence</em> of
/// <c>NoDA</c> is what puts every wrong PIN on the dictionary-attack counter. Removing that one
/// attribute would leave a four-digit secret that could be guessed at full speed by anything holding
/// the blob and the machine, which is the whole of the protection this class offers.
/// </remarks>
private static TpmPublic SealedTemplate => new(
TpmAlgId.Sha256,
ObjectAttr.FixedTPM | ObjectAttr.FixedParent | ObjectAttr.UserWithAuth,
null,
new KeyedhashParms(new NullSchemeKeyedhash()),
new Tpm2bDigestKeyedhash());
/// <summary>Whether this machine has a TPM this process may use.</summary>
/// <remarks>
/// Probes by creating and flushing a primary rather than by looking for the device node, for the
/// reason <c>WindowsDeviceKeyStore.IsSupported</c> gives about its own provider: the node can be
/// present on a machine where the first real command then fails. Permissions are the common case here
/// and they fail at open; an owner hierarchy with an authValue set is the rarer one and fails at
/// CreatePrimary. Neither is a fault and both mean the same thing.
/// </remarks>
internal static bool IsSupported() => IsSupported(static () => new LinuxTpmDevice());
/// <inheritdoc cref="IsSupported()" />
internal static bool IsSupported(Func<Tpm2Device> openDevice)
{
ArgumentNullException.ThrowIfNull(openDevice);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
tpm.FlushContext(primary);
return true;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Every way this fails means the same thing to the caller: no device key here, so keep asking
// for the passphrase. A missing device node throws IOException, a device this user may not
// open throws UnauthorizedAccessException, and a TPM that refuses the command throws
// TpmException — and on a machine with no TPM at all the constructor itself may throw before
// any of that.
return false;
}
}
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(IsSupported(openDevice));
/// <inheritdoc />
/// <remarks>
/// Asks for a PIN first and writes nothing if the user declines, which matters more here than the
/// symmetry suggests: <c>SaveAsync</c> is called as part of registering this machine, and a half-done
/// registration would leave a wrap on the server that nothing can open.
/// </remarks>
public async ValueTask SaveAsync(
ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken)
{
var pin = await askForPin(DevicePinPurpose.Choosing, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(pin))
{
throw new OperationCanceledException("No PIN was chosen, so no device key was stored.");
}
var key = devicePrivateKey.ToArray();
// Off the calling thread, because every command below blocks on the chip. Creating the primary
// alone is hundreds of milliseconds on real hardware, and this is called from the unlock path —
// which on the desktop head is the UI thread.
var packed = await Task.Run(() => Seal(pin, key), cancellationToken).ConfigureAwait(false);
paths.EnsureCreated();
await File.WriteAllBytesAsync(paths.DeviceKeyFile, packed, cancellationToken).ConfigureAwait(false);
}
/// <summary>Seals the key into the TPM and answers with the blob to store.</summary>
private byte[] Seal(string pin, byte[] devicePrivateKey)
{
var auth = PinAuth(pin);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
try
{
var priv = tpm.Create(
primary,
new SensitiveCreate(auth, devicePrivateKey),
SealedTemplate,
null,
[],
out var pub,
out _,
out _,
out _);
return Pack(pub, priv);
}
finally
{
tpm.FlushContext(primary);
}
}
finally
{
CryptographicOperations.ZeroMemory(auth);
CryptographicOperations.ZeroMemory(devicePrivateKey);
}
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>One attempt per call, and a wrong PIN is not retried here.</b> Every wrong answer advances the
/// TPM's lockout counter, so a loop offering three tries would spend a third of the machine's budget
/// on one mistyped digit. Returning null lands on the passphrase, which always works; somebody who
/// wants to try the PIN again asks for a device unlock again, which is a deliberate act rather than a
/// loop.
/// </para>
/// <para>
/// <b>A locked-out TPM is deliberately indistinguishable from a wrong PIN.</b>
/// <see cref="IDeviceKeyStore.TryLoadAsync"/> asks for exactly that and the reasoning holds here: the
/// caller does the same thing in every case, and "your TPM is in lockout" describes the keystore
/// rather than telling somebody something they can act on — the answer is still to type the
/// passphrase.
/// </para>
/// </remarks>
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(paths.DeviceKeyFile))
{
return null;
}
var stored = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false);
if (!TryUnpack(stored, out var pub, out var priv))
{
return null;
}
// After the blob is known to be readable and before anything is asked of the user, because a
// prompt raised over a file this cannot use is a question with no useful answer.
var pin = await askForPin(DevicePinPurpose.Entering, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(pin))
{
return null;
}
return await Task.Run(() => Unseal(pin, pub, priv), cancellationToken).ConfigureAwait(false);
}
/// <summary>Asks the TPM to release the key, and answers null however it declines.</summary>
private byte[]? Unseal(string pin, TpmPublic pub, TpmPrivate priv)
{
var auth = PinAuth(pin);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
try
{
var loaded = tpm.Load(primary, priv, pub);
try
{
return tpm[auth].Unseal(loaded);
}
finally
{
tpm.FlushContext(loaded);
}
}
finally
{
tpm.FlushContext(primary);
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// TpmException covers the wrong PIN and the lockout; the rest covers a TPM that has gone away
// between the availability check and now, which a suspend or a firmware update can do.
return null;
}
finally
{
CryptographicOperations.ZeroMemory(auth);
}
}
/// <inheritdoc />
/// <remarks>
/// Deleting the blob is the whole of it, and that is a property of sealing rather than an omission.
/// The object lives nowhere but in that file — it was never made persistent inside the TPM, see
/// <see cref="PrimaryTemplate"/> — so the bytes are unrecoverable the moment it is gone. There is no
/// TPM state to clean up and nothing left behind for a later install to trip over.
/// </remarks>
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
if (File.Exists(paths.DeviceKeyFile))
{
File.Delete(paths.DeviceKeyFile);
}
return ValueTask.CompletedTask;
}
/// <summary>Derives the TPM authValue from the PIN as typed.</summary>
private static byte[] PinAuth(string pin) =>
SHA256.HashData(Encoding.UTF8.GetBytes($"{PinLabel} {pin}"));
private static TpmHandle CreatePrimary(Tpm2 tpm) =>
tpm.CreatePrimary(
TpmRh.Owner,
new SensitiveCreate(),
PrimaryTemplate,
null,
[],
out _,
out _,
out _,
out _);
/// <summary>Puts the two halves of the sealed object in one file.</summary>
/// <remarks>
/// Both are needed to load it and neither is secret — the private half is encrypted to this TPM's
/// storage hierarchy, which is what makes the file useless anywhere else. Length-prefixed rather than
/// concatenated because their sizes vary with the TPM's algorithms, and magic-prefixed so that a file
/// left by another version, or by the Windows store on a dual-boot profile directory, is refused
/// rather than fed to the TPM as a malformed structure.
/// </remarks>
private static byte[] Pack(TpmPublic pub, TpmPrivate priv)
{
var publicPart = pub.GetTpmRepresentation();
var privatePart = priv.GetTpmRepresentation();
var packed = new byte[Magic.Length + 4 + publicPart.Length + 4 + privatePart.Length];
var at = Magic.Length;
Magic.CopyTo(packed.AsSpan());
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), publicPart.Length);
at += 4;
publicPart.CopyTo(packed.AsSpan(at));
at += publicPart.Length;
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), privatePart.Length);
at += 4;
privatePart.CopyTo(packed.AsSpan(at));
return packed;
}
/// <summary>Reads back what <see cref="Pack"/> wrote, or answers false.</summary>
private static bool TryUnpack(byte[] stored, out TpmPublic pub, out TpmPrivate priv)
{
pub = new TpmPublic();
priv = new TpmPrivate();
try
{
if (stored.Length < Magic.Length + 8 || !stored.AsSpan(0, Magic.Length).SequenceEqual(Magic))
{
return false;
}
var at = Magic.Length;
var publicLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
at += 4;
if (publicLength < 0 || at + publicLength + 4 > stored.Length)
{
return false;
}
var publicPart = stored.AsSpan(at, publicLength).ToArray();
at += publicLength;
var privateLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
at += 4;
if (privateLength < 0 || at + privateLength != stored.Length)
{
return false;
}
var privatePart = stored.AsSpan(at, privateLength).ToArray();
pub = Marshaller.FromTpmRepresentation<TpmPublic>(publicPart);
priv = Marshaller.FromTpmRepresentation<TpmPrivate>(privatePart);
return true;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// A blob that does not parse is a blob this machine cannot use, which is the same answer as no
// blob at all. Throwing here would mean a corrupted file stopped the application from starting.
return false;
}
}
}
@@ -0,0 +1,495 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>What a channel's release says it is publishing.</summary>
/// <remarks>
/// <para>
/// Written by <c>scripts/ci-linux-rpm.sh</c> and read here, and it is a small file this repository
/// controls rather than the forge's own release JSON for the reason the android manifest gives: this is
/// fetched on a six-hourly timer, and a document whose shape changes when the forge is upgraded is not a
/// contract.
/// </para>
/// <para>
/// <b>The comparison is <see cref="RpmVersion"/> and never <see cref="Version"/>.</b> That is the string
/// <c>rpm</c> will order the install against, so comparing anything else would let this offer an update
/// PackageKit then refuses — the same argument <c>AndroidChannelManifest</c> makes for <c>versionCode</c>.
/// The two differ exactly where it matters: <c>0.0.1-alpha.0.143</c> and <c>0.0.1~alpha.0.143</c> are one
/// build, and only the second sorts below <c>0.0.1</c>.
/// </para>
/// </remarks>
/// <param name="RpmVersion">The version-release string rpm knows the package by.</param>
/// <param name="Version">What that build calls itself, for a human.</param>
/// <param name="Rpm">The asset on the same release that holds it.</param>
/// <param name="Sha256">The package's digest, lower-case hex.</param>
internal sealed record LinuxChannelManifest(
[property: JsonPropertyName("rpmVersion")] string RpmVersion,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("rpm")] string Rpm,
[property: JsonPropertyName("sha256")] string Sha256);
/// <summary>One release as the forge describes it. Only the assets are read.</summary>
internal sealed record LinuxForgeRelease(
[property: JsonPropertyName("assets")] IReadOnlyList<LinuxForgeAsset>? Assets);
/// <summary>One file attached to a release.</summary>
internal sealed record LinuxForgeAsset(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("browser_download_url")] string? DownloadUrl);
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(LinuxChannelManifest))]
[JsonSerializable(typeof(LinuxForgeRelease))]
internal sealed partial class LinuxForgeJsonContext : JsonSerializerContext;
/// <summary>
/// Fetches a newer RPM from the project's own forge and asks the system's package manager to install it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here replaces anything.</b> That is the difference from
/// <see cref="VelopackUpdateChannel"/> and it is the decision ADR 0013 rule 11 records rather than an
/// unfinished half: on this platform the package manager is already an updater the user trusts, audits and
/// controls, so this class does the two things the package manager cannot — notice that a build exists and
/// fetch it — and then hands the file to PackageKit, which raises polkit and asks. The shape is the
/// phone's, not the desktop's: <see cref="ApplyingEndsTheProcess"/> is false, and the answer may be no.
/// </para>
/// <para>
/// <b>The feed is a constant and the deployment is never asked.</b> ADR 0011 rule 2, carried unchanged
/// from both other channels: an operator who could answer the update check could pin a chosen user to a
/// build with a known hole, without holding any key at all.
/// </para>
/// <para>
/// <b>The digest is checked and is not a signature.</b> Velopack verifies what it downloads and Android
/// refuses a package whose signature does not match the installed one; an RPM fetched over HTTPS and
/// handed to PackageKit has neither. So the manifest names a digest and this refuses anything else —
/// which catches a truncated download, a caching proxy and a corrupted mirror, and catches nothing at all
/// about who built the file, because the same job wrote both. Provenance here is the constant address; a
/// signed repository is what would improve on it, and ADR 0013 rule 11 says why there is not one yet.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
internal sealed class LinuxUpdateChannel : IUpdateChannel
{
/// <summary>The project's own forge, and the one address in this file.</summary>
/// <remarks>
/// The owner half of the path matters for the reason <c>AndroidUpdateChannel</c> records: the
/// repository moved between organisations and Gitea leaves a 301 behind, so a client naming the old
/// one works until somebody deletes the redirect.
/// </remarks>
private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH";
private readonly string channel;
private readonly string packageName;
private readonly string dataDirectory;
private readonly HttpClient http;
private readonly Func<string, string?> installedVersion;
private readonly Action<string> handToInstaller;
private (string Version, string RpmVersion, string Url, string Sha256)? found;
private string? fetched;
/// <summary>Creates a channel for the running build.</summary>
internal LinuxUpdateChannel(string channel)
: this(
channel,
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
new HttpClient(),
InstalledRpmVersion,
HandToPackageKit)
{
}
/// <summary>Creates a channel with its outside world supplied, which is how the tests reach it.</summary>
internal LinuxUpdateChannel(
string channel,
string dataDirectory,
HttpClient http,
Func<string, string?> installedVersion,
Action<string> handToInstaller)
{
ArgumentException.ThrowIfNullOrEmpty(channel);
ArgumentException.ThrowIfNullOrEmpty(dataDirectory);
ArgumentNullException.ThrowIfNull(http);
ArgumentNullException.ThrowIfNull(installedVersion);
ArgumentNullException.ThrowIfNull(handToInstaller);
this.channel = channel;
this.dataDirectory = dataDirectory;
this.http = http;
this.installedVersion = installedVersion;
this.handToInstaller = handToInstaller;
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal)
? "dodossh-nightly"
: "dodossh";
}
/// <inheritdoc />
/// <remarks>
/// True unconditionally, because whether this copy is one that can be updated was already decided by
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at
/// composition rather than at every call site.
/// </remarks>
public bool IsSupported => true;
/// <inheritdoc />
public string CurrentVersion => ClientVersion.Current;
/// <inheritdoc />
/// <remarks>
/// False, and for the phone's reason rather than the desktop's. Applying hands a file to PackageKit
/// and returns; polkit then asks, and the user may say no. A caller that had already torn the vault
/// down would answer "not now" with a locked vault and every shell gone.
/// </remarks>
public bool ApplyingEndsTheProcess => false;
/// <inheritdoc />
/// <exception cref="HttpRequestException">The forge could not be reached, or refused.</exception>
/// <exception cref="InvalidOperationException">The release is there and does not carry a usable build.</exception>
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
{
var release = await ReadAsync(
ReleaseUrl(),
LinuxForgeJsonContext.Default.LinuxForgeRelease,
cancellationToken)
.ConfigureAwait(false);
var manifestName = $"linux-{channel}.json";
if (Asset(release, manifestName) is not { } manifestAsset)
{
throw new InvalidOperationException(
$"The {channel} release carries no {manifestName}, so there is nothing saying what it "
+ "publishes.");
}
var manifest = await ReadAsync(
manifestAsset,
LinuxForgeJsonContext.Default.LinuxChannelManifest,
cancellationToken)
.ConfigureAwait(false);
if (manifest is null)
{
throw new InvalidOperationException(
$"The {channel} release's {manifestName} could not be read as a manifest.");
}
// The one place null is returned, and it means what null is documented to mean: this build is
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0)
{
return null;
}
if (Asset(release, manifest.Rpm) is not { } rpm)
{
throw new InvalidOperationException(
$"The {channel} release advertises {manifest.Version} but carries no {manifest.Rpm}.");
}
found = (manifest.Version, manifest.RpmVersion, rpm, manifest.Sha256);
return new AvailableUpdate(manifest.Version);
}
/// <inheritdoc />
public async Task DownloadAsync(
AvailableUpdate update,
IProgress<int> progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(update);
ArgumentNullException.ThrowIfNull(progress);
if (found is not { } target
|| !string.Equals(target.Version, update.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException(
"That update did not come from this channel's last check. Check again before downloading.");
}
// Under the profile directory rather than /tmp, because a package this size on a tmpfs is a
// machine that runs out of memory downloading an update. One name, overwritten, so a client that
// fetched three updates it never installed is not holding three hundred megabytes.
var directory = Path.Combine(dataDirectory, "updates");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, $"{packageName}-update.rpm");
await FetchAsync(target.Url, path, progress, cancellationToken).ConfigureAwait(false);
// ◆ Verified before it is ever named to the installer, and the file is removed if it does not
// match. Leaving a package that failed its digest on disk would put a file somebody could install
// by hand next to a filename that says it is an update.
var digest = Convert.ToHexStringLower(await Sha256OfAsync(path, cancellationToken).ConfigureAwait(false));
if (!string.Equals(digest, target.Sha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(path);
throw new InvalidOperationException(
$"The downloaded package is not the one {channel} published: expected {target.Sha256}, "
+ $"got {digest}.");
}
fetched = path;
}
/// <summary>Streams one URL to one path, reporting progress as it goes.</summary>
private async Task FetchAsync(
string url,
string path,
IProgress<int> progress,
CancellationToken cancellationToken)
{
using var response = await http
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength ?? 0;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var destination = File.Create(path);
await using (stream.ConfigureAwait(false))
await using (destination.ConfigureAwait(false))
{
var buffer = new byte[81920];
long copied = 0;
int read;
while ((read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
.ConfigureAwait(false);
copied += read;
if (total > 0)
{
progress.Report((int)(copied * 100 / total));
}
}
}
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>This installs nothing and returns immediately.</b> It asks PackageKit — the freedesktop service
/// every RPM distribution runs — to install a local file, which raises a polkit prompt drawn by the
/// desktop rather than by this application. The user may refuse, and refusing is not a failure.
/// </para>
/// <para>
/// <b>The process is not restarted and must not be.</b> Replacing files under a running process is
/// ordinary on Linux: the old inodes stay mapped and this build keeps running until somebody closes
/// it. That is the right behaviour for an application that deliberately keeps shells open across a
/// lock — the same argument ADR 0013 makes for waiting for a restart the user asks for — and it is why
/// the name inherited from the interface reads oddly here. There is no equivalent of Android's
/// "the platform ends the process", so nothing ends it.
/// </para>
/// </remarks>
public void ApplyAndRestart(AvailableUpdate update)
{
ArgumentNullException.ThrowIfNull(update);
if (fetched is not { } path || !File.Exists(path))
{
throw new InvalidOperationException("There is no downloaded update to install.");
}
handToInstaller(path);
}
/// <summary>Which release this build's channel reads.</summary>
/// <remarks>
/// <c>releases/latest</c> skips prereleases, which keeps the nightly — published as one — out of the
/// release channel's answer even though both live on the same forge.
/// </remarks>
private string ReleaseUrl() =>
string.Equals(channel, "nightly", StringComparison.Ordinal)
? $"{RepositoryApi}/releases/tags/nightly-desktop"
: $"{RepositoryApi}/releases/latest";
private static string? Asset(LinuxForgeRelease? release, string name) =>
release?.Assets?.FirstOrDefault(
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
private async Task<T?> ReadAsync<T>(
string url,
JsonTypeInfo<T> shape,
CancellationToken cancellationToken)
{
var stream = await http.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
return await JsonSerializer
.DeserializeAsync(stream, shape, cancellationToken)
.ConfigureAwait(false);
}
}
private static async Task<byte[]> Sha256OfAsync(string path, CancellationToken cancellationToken)
{
var stream = File.OpenRead(path);
await using (stream.ConfigureAwait(false))
{
return await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// What rpm records for the installed package, which is the number a newer build must beat.
/// </summary>
/// <remarks>
/// <para>
/// Read from the package database rather than from the assembly, for the reason
/// <c>AndroidUpdateChannel.ReadInstalledVersionCode</c> gives about reading it from the platform: the
/// assembly's version is a SemVer string and this comparison has to be the one the installer will
/// make. Deriving the rpm version from the assembly here would mean a second copy of the translation
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
/// </para>
/// <para>
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then
/// an install PackageKit declines rather than a security fix that never arrives.
/// </para>
/// </remarks>
private static string? InstalledRpmVersion(string packageName)
{
try
{
using var process = Process.Start(new ProcessStartInfo("rpm")
{
ArgumentList = { "-q", "--queryformat", "%{VERSION}-%{RELEASE}", packageName },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
});
if (process is null)
{
return null;
}
var answer = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(TimeSpan.FromSeconds(10));
return process.ExitCode == 0 && answer.Length > 0 ? answer : null;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
return null;
}
}
/// <summary>Asks PackageKit to install a local file, and lets polkit do the asking.</summary>
/// <remarks>
/// <para>
/// <b>Over <c>gdbus</c> rather than a D-Bus library, and that is a dependency argument rather than a
/// stylistic one.</b> <c>gdbus</c> ships in glib2, which this package already depends on twice over —
/// GTK and WebKitGTK are the terminal — so it is present on every machine that can run this at all.
/// Adding a managed D-Bus stack to reach two method calls would be a package, a lock file entry and a
/// second thing to keep current, for no capability. <c>BrowserLauncher</c> settles the same trade the
/// same way one layer down.
/// </para>
/// <para>
/// <b>Two calls, because PackageKit's interface is a factory.</b> <c>CreateTransaction</c> returns an
/// object path and the work is asked of that; a transaction is single-use, so this cannot be cached.
/// </para>
/// <para>
/// <b>The flags word is zero, and zero is load-bearing.</b> Bit 0 is <c>ONLY_TRUSTED</c>. This
/// package is not signed — ADR 0013 rule 11 says why, and why that is not decision 8's "unsigned for
/// now" — so requiring trust would make every update fail at the last step. Clearing it is what routes
/// the request to polkit's <c>package-install-untrusted</c> action, which asks for an administrator
/// rather than proceeding quietly. The user sees what they are agreeing to.
/// </para>
/// </remarks>
private static void HandToPackageKit(string path)
{
var transaction = Gdbus(
"--system",
"--dest", "org.freedesktop.PackageKit",
"--object-path", "/org/freedesktop/PackageKit",
"--method", "org.freedesktop.PackageKit.CreateTransaction");
// gdbus prints a GVariant tuple — (objectpath '/17_beadcfae',) — and the path is the only thing
// in it. Parsed by taking what is between the quotes rather than with a regular expression over a
// format nobody has promised to keep.
var opened = transaction.IndexOf('\'', StringComparison.Ordinal);
var closed = transaction.LastIndexOf('\'');
if (opened < 0 || closed <= opened)
{
throw new InvalidOperationException(
$"PackageKit did not return a transaction to install into: {transaction}");
}
var objectPath = transaction[(opened + 1)..closed];
Gdbus(
"--system",
"--dest", "org.freedesktop.PackageKit",
"--object-path", objectPath,
"--method", "org.freedesktop.PackageKit.Transaction.InstallFiles",
"0",
$"['{path}']");
}
/// <summary>Runs one gdbus call and answers with its output.</summary>
private static string Gdbus(params string[] arguments)
{
var start = new ProcessStartInfo("gdbus")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var argument in arguments)
{
start.ArgumentList.Add(argument);
}
using var process = Process.Start(start)
?? throw new InvalidOperationException(
"gdbus could not be started, so the package could not be handed to PackageKit.");
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit(TimeSpan.FromSeconds(30));
if (process.ExitCode != 0)
{
// The message is shown to the user by UpdateViewModel, so it says what to do rather than what
// failed. A missing PackageKit is the likely cause on a minimal install and is the one thing
// they can fix.
throw new InvalidOperationException(
string.Format(
CultureInfo.CurrentCulture,
"PackageKit would not take the package. Install it with `sudo dnf install PackageKit`, "
+ "or install the file yourself: {0}. ({1})",
arguments[^1],
error.Trim()));
}
return output;
}
}
@@ -0,0 +1,213 @@
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Compares two RPM version strings the way <c>rpm</c> itself does.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists rather than a SemVer comparison.</b> <c>AndroidUpdateChannel</c> settles the same
/// question and its reasoning carries over exactly: the comparison has to be the one the installer will
/// make, because anything else can offer an update the platform then refuses. On the phone that number is
/// Android's <c>versionCode</c>; here it is the RPM version, and the authority that reads it is
/// <c>rpm</c> — so this is <c>rpmvercmp</c> and not an approximation of it.
/// </para>
/// <para>
/// It matters in a specific direction. The nightly channel is rolling and CI replaces it on every push, so
/// the published version normally only rises — but a revert, a force-push or a re-run of an older commit
/// can lower it, and a client comparing "is this different from mine" would then offer a downgrade that
/// PackageKit declines. The user sees an update that fails, every six hours, forever.
/// </para>
/// <para>
/// <b>The algorithm is rpm's, including the parts that look like bugs.</b> Segments of digits and segments
/// of letters are compared separately and never against each other; everything that is neither is a
/// separator, so <c>1.0_1</c> and <c>1.0.1</c> are equal. Leading zeros are stripped, so <c>1.01</c> and
/// <c>1.1</c> are equal too. <c>~</c> sorts before anything including the end of the string, which is what
/// makes <c>0.0.1~alpha.0.143</c> older than <c>0.0.1</c> and is the whole reason
/// <c>scripts/ci-linux-rpm.sh</c> translates a SemVer prerelease to one. <c>^</c> sorts after everything
/// except the end of the string.
/// </para>
/// <para>
/// Verified against <c>rpm</c>'s own answers rather than against a reading of the source: the cases in
/// <c>RpmVersionTests</c> were generated by running <c>rpm.vercmp</c> in a container, so a disagreement is
/// a disagreement with the program that will actually decide.
/// </para>
/// </remarks>
internal static class RpmVersion
{
/// <summary>
/// Orders two RPM version strings.
/// </summary>
/// <param name="left">The left version.</param>
/// <param name="right">The right version.</param>
/// <returns>-1, 0 or 1, as <c>rpmvercmp</c> would answer.</returns>
/// <remarks>
/// Null and empty are accepted and treated as equal to each other and older than anything else, which
/// <c>rpm</c>'s own Lua binding refuses outright. The refusal is right for a packaging tool and wrong
/// here: this compares a version read off a feed, and a feed that answered with an empty string should
/// leave the client on the build it has rather than throwing on a background timer.
/// </remarks>
internal static int Compare(string? left, string? right)
{
var one = left ?? string.Empty;
var two = right ?? string.Empty;
if (string.Equals(one, two, StringComparison.Ordinal))
{
return 0;
}
int i = 0, j = 0;
while (i < one.Length || j < two.Length)
{
// Separators. Anything that is not alphanumeric, ~ or ^ divides segments and is skipped on
// both sides independently — which is why 1.0_1 and 1.0.1 compare equal.
Skip(one, ref i);
Skip(two, ref j);
if (CompareMarkers(one, i, two, j) is { } marker)
{
if (marker != 0)
{
return marker;
}
// Both sides carried the same marker, so step over it and keep going.
i++;
j++;
continue;
}
if (i >= one.Length || j >= two.Length)
{
break;
}
if (CompareSegments(one, ref i, two, ref j) is { } ordering)
{
return ordering;
}
}
// Both ran out together, so every segment matched and the only differences were separators.
if (i >= one.Length && j >= two.Length)
{
return 0;
}
// Whichever still has characters left has more version, and more version is newer.
return i < one.Length ? 1 : -1;
}
/// <summary>Advances past anything that divides segments rather than taking part in them.</summary>
private static void Skip(string value, ref int at)
{
while (at < value.Length && !IsVersionCharacter(value[at]))
{
at++;
}
}
/// <summary>
/// Orders the two sides when either is at a <c>~</c> or a <c>^</c>.
/// </summary>
/// <returns>
/// Null when neither side is at a marker, so the caller should compare segments; 0 when both are at
/// the same one, so the caller should step over it; otherwise the ordering.
/// </returns>
/// <remarks>
/// <c>~</c> sorts before everything including the end of the string, which is what carries the whole
/// prerelease ordering. <c>^</c> sorts after everything <em>except</em> the end of the string, so
/// 1.0^post is newer than 1.0 and 1.2.3 is older than 1.2.3^ — the end-of-string checks coming first
/// is the difference between the two halves of that sentence.
/// </remarks>
private static int? CompareMarkers(string one, int i, string two, int j)
{
var oneTilde = i < one.Length && one[i] == '~';
var twoTilde = j < two.Length && two[j] == '~';
if (oneTilde || twoTilde)
{
return oneTilde && twoTilde ? 0 : oneTilde ? -1 : 1;
}
var oneCaret = i < one.Length && one[i] == '^';
var twoCaret = j < two.Length && two[j] == '^';
if (!oneCaret && !twoCaret)
{
return null;
}
if (i >= one.Length)
{
return -1;
}
if (j >= two.Length)
{
return 1;
}
return oneCaret && twoCaret ? 0 : oneCaret ? -1 : 1;
}
/// <summary>
/// Takes one segment from each side and orders them, advancing past both.
/// </summary>
/// <returns>The ordering, or null if the two segments were equal and the walk should continue.</returns>
private static int? CompareSegments(string one, ref int i, string two, ref int j)
{
var numeric = char.IsAsciiDigit(one[i]);
var start1 = i;
var start2 = j;
Take(one, ref i, numeric);
Take(two, ref j, numeric);
var segment1 = one.AsSpan(start1, i - start1);
var segment2 = two.AsSpan(start2, j - start2);
// The two sides are at segments of different kinds — digits against letters. rpm calls the
// numeric one newer, always, on the reasoning that a version that has started counting is further
// along than one still spelling. segment1 is never empty: its kind was chosen from its own first
// character.
if (segment2.IsEmpty)
{
return numeric ? 1 : -1;
}
if (numeric)
{
segment1 = segment1.TrimStart('0');
segment2 = segment2.TrimStart('0');
// Longer, once the zeros are gone, is larger. Compared before the characters are, because a
// string comparison would call "9" larger than "10".
if (segment1.Length != segment2.Length)
{
return segment1.Length > segment2.Length ? 1 : -1;
}
}
var ordering = segment1.CompareTo(segment2, StringComparison.Ordinal);
return ordering == 0 ? null : ordering > 0 ? 1 : -1;
}
/// <summary>Advances over one run of digits, or one run of letters.</summary>
private static void Take(string value, ref int at, bool numeric)
{
while (at < value.Length
&& (numeric ? char.IsAsciiDigit(value[at]) : char.IsAsciiLetter(value[at])))
{
at++;
}
}
/// <summary>Whether this character takes part in the comparison rather than dividing it.</summary>
private static bool IsVersionCharacter(char value) =>
char.IsAsciiLetterOrDigit(value) || value == '~' || value == '^';
}
@@ -0,0 +1,372 @@
using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// One desktop instance per profile, and a way for a second launch to raise the first.
/// </summary>
/// <remarks>
/// <para>
/// <b>Per profile rather than per machine, which decides two things at once.</b> The lock lives in
/// <see cref="ClientPaths.InstanceLockFile"/>, so a nightly and a release build never exclude each other —
/// they are separate applications by design, ADR 0013 — and two people signed in to one machine each get
/// their own instance. A global mutex would have had to re-derive both of those from somewhere.
/// </para>
/// <para>
/// <b>Why one instance at all.</b> The cache is a SQLite file with an outbox in it, and the whole design
/// assumes one writer: <see cref="ClientPaths"/> says so about two machines sharing a profile through a
/// cloud sync client, and two processes on one machine is the same hazard with a shorter path to it. The
/// terminal's loopback listener is the visible half — the second process fails to bind, gets a port of its
/// own or refuses to start a workspace at all — and the cache is the half that would quietly corrupt.
/// </para>
/// <para>
/// <b>A file handle rather than a named mutex</b>, because the operating system releases it when the
/// process ends <em>however</em> it ends. A named mutex abandoned by a killed process is a state both
/// platforms model differently and neither models simply; an exclusive handle is closed by the kernel on
/// Windows and by the file descriptor going away on Unix, so there is no such thing as a stale lock here.
/// </para>
/// <para>
/// <b>And a named pipe for the other half, because exiting silently is not an answer.</b> Somebody who
/// double-clicks the icon and sees nothing happen has been told the application is broken. The second
/// process asks the first to come forward and then leaves, which is what every desktop application does and
/// what makes the exclusion invisible rather than mysterious.
/// </para>
/// </remarks>
internal static class SingleInstance
{
/// <summary>The one thing a second launch has to say.</summary>
/// <remarks>
/// Compared against a constant rather than parsed, for the reason <c>MainWindow</c>'s release-focus
/// message is: one message exists and treating anything else as a command would be a wider door than
/// this needs. Anything may connect to a per-user pipe — it is not a security boundary — so what arrives
/// is checked rather than trusted.
/// </remarks>
private const string ComeForward = "dodossh.come-forward";
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
/// <remarks>
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
/// not a bound on anything slow; it is how long to wait before concluding that whoever holds the lock
/// cannot be talked to.
/// </remarks>
private const int AnswerTimeoutMilliseconds = 500;
/// <summary>How long to keep trying for a lock that is held by nobody who answers.</summary>
/// <remarks>
/// <para>
/// ◆ <b>This is the window an update relaunch lands in, and without it an update would look like a
/// crash.</b> Velopack's apply ends this process and its updater then starts the new build; the two are
/// ordered — the updater waits on the old process id — but the whole sequence is one where a lock held a
/// moment too long would make the relaunched application exit on the spot, having apparently uninstalled
/// itself. The same window covers an ordinary double-double-click, where the first process holds the file
/// and has not reached its pipe yet.
/// </para>
/// <para>
/// Only reached when nobody answers the pipe. A live instance replies in milliseconds and the second
/// launch is gone before any of this matters.
/// </para>
/// </remarks>
private static readonly TimeSpan DefaultPatience = TimeSpan.FromSeconds(3);
/// <summary>How long to wait between attempts within that window.</summary>
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100);
/// <summary>
/// Claims this profile for this process, or asks whoever holds it to come forward.
/// </summary>
/// <returns>
/// A handle to hold for the life of the process, or <see langword="null"/> when another instance has it —
/// in which case that instance has already been asked to raise its window and this process should end.
/// </returns>
/// <param name="paths">The profile to claim. Its directory is created if it is not there.</param>
/// <param name="patience">
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
/// </param>
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
{
ArgumentNullException.ThrowIfNull(paths);
try
{
paths.EnsureCreated();
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// No profile directory means no lock file, and every build before this one started anyway. What
// follows will fail on its own terms and say something better than this could.
return NoClaim.Instance;
}
// Counted rather than clocked. Thread.Sleep is not a clock and reading one here would be pretending
// otherwise — what this bounds is the number of attempts, and the wall time is whatever the platform
// makes of them.
var attemptsLeft = (int)Math.Ceiling(
(patience ?? DefaultPatience).TotalMilliseconds / RetryDelay.TotalMilliseconds);
while (true)
{
if (TryTake(paths.InstanceLockFile) is { } taken)
{
return taken;
}
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
// round trip on a local pipe rather than the whole window below.
if (AskTheRunningOneToComeForward(paths))
{
return null;
}
if (attemptsLeft-- <= 0)
{
// Held by something that will not talk. Giving up rather than starting anyway, because what
// is being protected is a database with one writer, and "probably nobody" is not an answer
// worth corrupting it for.
return null;
}
Thread.Sleep(RetryDelay);
}
}
/// <summary>
/// Answers the launches that were refused, for as long as the returned handle is held.
/// </summary>
/// <remarks>
/// <paramref name="comeForward"/> is invoked on a thread pool thread, because that is where a pipe
/// accept completes. A caller that touches a window has to marshal.
/// </remarks>
/// <param name="paths">The profile this instance claimed.</param>
/// <param name="comeForward">What to do when another launch asks for the window.</param>
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(comeForward);
var listener = new Doorbell(PipeNameFor(paths), comeForward);
listener.Start();
return listener;
}
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
/// <returns>Whether anything was listening.</returns>
/// <remarks>
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
/// and proves itself, and this is the part with a protocol in it.
/// </remarks>
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
try
{
using var pipe = new NamedPipeClientStream(
serverName: ".",
PipeNameFor(paths),
PipeDirection.Out);
pipe.Connect(AnswerTimeoutMilliseconds);
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
writer.WriteLine(ComeForward);
return true;
}
catch (Exception exception)
when (exception is TimeoutException or IOException or UnauthorizedAccessException)
{
// Nothing there, or nothing willing. The caller decides what that means; here it is only ever
// "no".
return false;
}
}
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
/// <remarks>
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
/// tidier and is worse: it makes the file's existence part of the protocol, so a second process opening
/// it while the first is closing gets a handle to something on its way out. An empty file that is always
/// there and sometimes locked has one fewer state.
/// </remarks>
private static IDisposable? TryTake(string lockFile)
{
try
{
return new FileStream(
lockFile,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
FileOptions.None);
}
catch (IOException)
{
// Somebody else holds it. The one expected failure, and the whole point.
return null;
}
catch (UnauthorizedAccessException)
{
// A profile this user cannot write to, which is a broken installation rather than a second
// instance. Answered as a claim so the application starts and fails on something legible.
return NoClaim.Instance;
}
}
/// <summary>
/// The pipe both sides name, derived from the profile so that it is scoped exactly as the lock is.
/// </summary>
/// <remarks>
/// A digest of the path rather than the path itself: a pipe name may not contain a directory separator
/// on Windows, and on Unix the name becomes a file in a temporary directory whose length is bounded.
/// Eight bytes of SHA-256, which is not doing security work — it is making one string out of another
/// deterministically, and <c>string.GetHashCode</c> cannot because it is randomised per process.
/// </remarks>
private static string PipeNameFor(ClientPaths paths)
{
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(paths.DataDirectory));
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
}
/// <summary>A claim over nothing, for the cases where there was nothing to claim.</summary>
private sealed class NoClaim : IDisposable
{
internal static NoClaim Instance { get; } = new();
public void Dispose()
{
// Nothing was taken.
}
}
/// <summary>The accept loop that answers refused launches.</summary>
/// <remarks>
/// <para>
/// One server instance for the life of the doorbell, disconnected between callers rather than disposed
/// and rebuilt. The loop is what makes a third and fourth launch work as well as the second.
/// </para>
/// <para>
/// ◆ <b>THE ONE INSTANCE IS THE WHOLE CORRECTNESS OF THIS CLASS ON LINUX, AND A FRESH ONE PER CALLER
/// LOSES LAUNCHES.</b> This was written the obvious way — a <c>using</c> inside the loop, a new stream
/// per connection — which is fine on Windows and quietly wrong on Unix, where a named pipe is a Unix
/// domain socket. Disposing the server there closes the <em>listening</em> socket and unlinks its path,
/// so a second launch that connected while the first was being served has its connection dropped on the
/// floor: its <c>Connect</c> returned, its write succeeded into a buffer, and nobody ever read it. The
/// launch was told it had rung the doorbell and the window never came forward.
/// </para>
/// <para>
/// Measured rather than reasoned about, in a container, because the two platforms disagree and this
/// machine is only one of them: three rings back to back are answered three times with one instance and
/// once with a fresh one per connection. <c>Disconnect</c> ends the conversation and keeps the socket, so
/// there is never a moment with nothing listening.
/// </para>
/// <para>
/// Which makes <c>leaveOpen</c> on the reader load-bearing as well. A <see cref="StreamReader"/> disposes
/// the stream it was given, and the stream is now the server that has to survive into the next
/// iteration — so the reader that was harmless per-connection would close the doorbell after one ring.
/// </para>
/// </remarks>
private sealed class Doorbell : IAsyncDisposable
{
private readonly string name;
private readonly Action comeForward;
private readonly CancellationTokenSource lifetime = new();
private Task? loop;
internal Doorbell(string name, Action comeForward)
{
this.name = name;
this.comeForward = comeForward;
}
internal void Start() => loop = RunAsync(lifetime.Token);
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await lifetime.CancelAsync().ConfigureAwait(false);
if (loop is { } running)
{
// Awaited rather than abandoned, so the pipe name is free by the time this returns — which
// matters for exactly one caller: an update that is about to relaunch this application and
// would otherwise race its own successor for the name.
await running.ConfigureAwait(false);
}
lifetime.Dispose();
}
private async Task RunAsync(CancellationToken cancellationToken)
{
using var server = new NamedPipeServerStream(
name,
PipeDirection.In,
maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
while (!cancellationToken.IsCancellationRequested)
{
try
{
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
// leaveOpen, because this server outlives the conversation. See the remark above.
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
{
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
{
comeForward();
}
}
HangUp(server);
}
catch (OperationCanceledException)
{
return;
}
catch (IOException)
{
// A client that connected and hung up mid-sentence. Round again: giving up here would
// leave every later launch with nothing to talk to, which is the silent-exit behaviour
// this class exists to avoid.
HangUp(server);
}
}
}
/// <summary>Ends one conversation without ending the doorbell.</summary>
/// <remarks>
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
/// an <see cref="IOException"/> above may already have left it in, and the state a second call would
/// find. Throwing out of the error path would take the loop with it.
/// </remarks>
private static void HangUp(NamedPipeServerStream server)
{
try
{
if (server.IsConnected)
{
server.Disconnect();
}
}
catch (InvalidOperationException)
{
// Already disconnected. Nothing to end.
}
}
}
}
@@ -1,3 +1,4 @@
using System.Runtime.Versioning;
using DodoSSH.Client.Session;
using Velopack;
using Velopack.Sources;
@@ -31,11 +32,18 @@ internal static class UpdateChannels
/// </remarks>
internal static IUpdateChannel ForThisMachine()
{
// Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches
// this too. Velopack has a Linux path — AppImage — but this repository does not build one, so a
// Linux build is a checkout somebody ran, and handing it an UpdateManager would have it poll a
// feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future
// AppImage an addition here rather than a thing that silently already half-happened.
// ◆ Linux is answered first and separately, because it is not a Velopack platform at all.
//
// This used to be the place that said so — a negation over the two packaged platforms, with a note
// that a future AppImage should be an addition here rather than something that had silently
// half-happened. It is an addition, and it is not an AppImage: the Linux head ships as an RPM and
// is updated by the machine's own package manager, so its channel fetches and hands off rather
// than replacing anything. See LinuxUpdateChannel and ADR 0013 rule 11.
if (OperatingSystem.IsLinux())
{
return LinuxChannel();
}
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
{
return new UnavailableUpdateChannel();
@@ -57,6 +65,35 @@ internal static class UpdateChannels
return new UnavailableUpdateChannel();
}
}
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
/// <remarks>
/// <para>
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
/// out.
/// </para>
/// <para>
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
/// differently. The install root is a constant in two places — here and the packaging script — and
/// they are named in each other's comments.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
private static IUpdateChannel LinuxChannel()
{
var root = DesktopChannel.IsNightly ? "/opt/DodoSSH.Nightly" : "/opt/DodoSSH";
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
return string.Equals(here, root, StringComparison.Ordinal)
? new LinuxUpdateChannel(DesktopChannel.Name)
: new UnavailableUpdateChannel();
}
}
/// <summary>
@@ -167,6 +204,25 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
/// </remarks>
private UpdateInfo? found;
/// <summary>
/// The last thing actually fetched, kept separately from the last thing found.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Because the two can disagree, and when they do it is an apply that pays for it.</b> A check
/// overwrites <see cref="found"/>, and the shell now checks while a build is already downloaded and
/// waiting — that is how it replaces a pending build when the feed has moved on, and how it takes one
/// last look on the way to a restart. Either of those can land a newer answer in <c>found</c> and then
/// fail to fetch it, or be given up on halfway; the shell's answer is to restart with the build it
/// already has, and <see cref="Matched"/> resolving only against <c>found</c> would have thrown at it.
/// </para>
/// <para>
/// Which turned a slow forge into a refused restart: the offer on screen was real, the package on disk
/// was real, and the only thing wrong was that this class had forgotten which one it was.
/// </para>
/// </remarks>
private UpdateInfo? fetched;
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
/// <inheritdoc />
@@ -284,7 +340,23 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
// Velopack reports progress as an Action<int> and the rest of this codebase speaks IProgress<T>,
// so the adaptation happens here rather than leaking the older shape into the view models.
return manager.DownloadUpdatesAsync(Matched(update), progress.Report, cancellationToken);
return DownloadAndRememberAsync(Matched(update), progress, cancellationToken);
}
/// <remarks>
/// Recorded after the await and not before, so that a download which threw leaves the previous fetch as
/// the one this channel can still apply. See <see cref="fetched"/>.
/// </remarks>
private async Task DownloadAndRememberAsync(
UpdateInfo info,
IProgress<int> progress,
CancellationToken cancellationToken)
{
await manager
.DownloadUpdatesAsync(info, progress.Report, cancellationToken)
.ConfigureAwait(false);
fetched = info;
}
/// <inheritdoc />
@@ -298,21 +370,39 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
}
/// <remarks>
/// <para>
/// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
/// <see cref="CheckAsync"/> returned; a mismatch is a bug in this application rather than anything a
/// user did, which is why it throws rather than resolving to some safe-looking default.
/// <see cref="CheckAsync"/> returned, or what a download of that then fetched; a mismatch is a bug in
/// this application rather than anything a user did, which is why it throws rather than resolving to
/// some safe-looking default.
/// </para>
/// <para>
/// What was fetched is tried before what was found, because that is the one an apply names and the one a
/// later check cannot take away. See <see cref="fetched"/> for how the two come to disagree.
/// </para>
/// </remarks>
private UpdateInfo Matched(AvailableUpdate update)
{
if (found is not { } info
|| !string.Equals(info.TargetFullRelease.Version.ToString(), update.Version, StringComparison.Ordinal))
if (Names(fetched, update))
{
throw new InvalidOperationException(
$"No update matching {update.Version} has been found by this channel. "
+ "Call CheckAsync and pass back what it returned.");
return fetched!;
}
return info;
if (Names(found, update))
{
return found!;
}
throw new InvalidOperationException(
$"No update matching {update.Version} has been found or fetched by this channel. "
+ "Call CheckAsync and pass back what it returned.");
}
private static bool Names(UpdateInfo? info, AvailableUpdate update) =>
info is not null
&& string.Equals(
info.TargetFullRelease.Version.ToString(),
update.Version,
StringComparison.Ordinal);
}
@@ -9,17 +9,26 @@ namespace DodoSSH.Client.App.Platform;
/// </summary>
/// <remarks>
/// <para>
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware,
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
/// keeps asking for the passphrase — which is the honest answer rather than a degraded one.
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware
/// gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the passphrase — which is
/// the honest answer rather than a degraded one.
/// </para>
/// <para>
/// <b>Both real stores are asked whether they work rather than told that they do.</b> Each
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and deleting it — because on
/// both platforms the provider is present and reports itself present on machines where creating a key
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, and on macOS also every
/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would
/// mean each of those discovering the truth at the moment somebody tried to unlock.
/// <b>Every real store is asked whether it works rather than told that it does.</b> Each
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and discarding it — because on
/// all three platforms the provider is present and reports itself present on machines where creating a key
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, on macOS also every unsigned
/// development build since enclave keys need a signing identity, and on Linux the ordinary case of a
/// working TPM whose device node this user may not open. Inferring from the OS would mean each of those
/// discovering the truth at the moment somebody tried to unlock.
/// </para>
/// <para>
/// <b>The three are not equally strong, and the ordering here does not imply they are.</b> Windows and
/// macOS put the consent gesture inside the platform — a secure desktop and a system Touch ID sheet — so
/// the process asking never sees the secret. The Linux store's PIN is typed into a window this
/// application draws, and only the <em>checking</em> is the TPM's. That is a real difference, it is
/// recorded in ADR 0007 rather than smoothed over, and it is why <see cref="LinuxDeviceKeyStore"/> is
/// tried last rather than first among platforms that could in principle overlap.
/// </para>
/// <para>
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
@@ -33,9 +42,20 @@ namespace DodoSSH.Client.App.Platform;
public static class DesktopDeviceKeyStores
{
/// <summary>The best store this machine supports.</summary>
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
/// <param name="paths">Where a stored key lives.</param>
/// <param name="askForPin">
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
/// raises its own dialog and this process is not in the conversation. A caller with no way to draw one
/// may pass a delegate answering null, which makes the Linux store decline every unlock; that is the
/// right shape for a headless host and the wrong one for the application, which supplies a real dialog
/// in <c>App.axaml.cs</c>.
/// </param>
public static IDeviceKeyStore ForThisMachine(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(askForPin);
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
{
@@ -47,6 +67,11 @@ public static class DesktopDeviceKeyStores
return new MacDeviceKeyStore(paths);
}
if (OperatingSystem.IsLinux() && LinuxDeviceKeyStore.IsSupported())
{
return new LinuxDeviceKeyStore(paths, askForPin);
}
return new UnavailableDeviceKeyStore();
}
}
+21
View File
@@ -37,6 +37,27 @@ internal static class Program
KeepTheWebViewProfileOutOfTheInstallDirectory();
/*
One instance per profile, and nothing above this line is affected by it: the installer's own runs
have already exited inside VelopackApp.Run, and the WebView2 environment variable is a property of
this process rather than of the application.
Claimed here rather than after Avalonia is configured, so that a second launch starts nothing — no
toolkit, no window, no splash flashing on screen before it thinks better of it. What it does
instead is ask the running instance to come forward, which is why this is not simply an exit.
The handle is held for as long as Main runs, which is as long as the message loop does. See
SingleInstance for why it is a file rather than a mutex, and for what the profile scoping buys.
*/
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
using var claim = SingleInstance.Claim(paths);
if (claim is null)
{
return;
}
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
@@ -111,13 +111,33 @@
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
Margin="24">
<!--
Which machine, and at what address. Both selectable, for the reason the session column's own address
is — see SessionSidebar.axaml: a TextBlock in Avalonia is a drawing of a string rather than a string,
and this pair is the one place the shell says what it is dialling *before* anything has answered.
That is exactly when the value is wanted somewhere else: in a ticket, in a message to whoever owns
the host, in the ssh command being written in the window beside this one. It is also the moment it is
least available anywhere else, because there is no prompt yet and may never be one — a refused
connection leaves this card holding the only copy on screen.
Both rather than only the name. They are two lines of one identifier, and a pair where the first can
be taken and the second cannot is a worse answer than either.
The trimming stays, and the tooltips are what make it safe rather than a trap. A drag reaches only
the glyphs that were drawn, so a name or an address wearing an ellipsis has to be taken with
select-all — Ctrl+A, then Ctrl+C — which takes the whole value, because the ellipsis is a drawing and
not a shorter string. The hover says what the whole value is, so nobody copies half of one without
having been told there is a half. Same argument, same shape, as the sidebar's.
-->
<StackPanel Spacing="6" HorizontalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16" FontWeight="Medium"
Foreground="{StaticResource Text}" HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16"
FontWeight="Medium" Foreground="{StaticResource Text}"
HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding SelectedTab.Label}" />
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding SelectedTab.Address}" />
</StackPanel>
<!--
@@ -181,6 +201,19 @@
in the strip and the card is still here on the way back.
-->
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
<!--
TRY AGAIN rather than RECONNECT, because nothing was ever connected: this tab is a handshake that
was refused. It runs the same command the disconnect strip's RECONNECT does — the vault's own
connect path, into this same tab — and it is worth having here for the reason the commonest
refusal is worth retrying at all: a host that was not up yet is a host that will be in a minute.
Disabled rather than hidden when there is nothing to dial, exactly as the strip's is. See
MainWindowViewModel.CanReconnectSelectedTab.
-->
<Button Classes="ghost" Content="TRY AGAIN" Command="{Binding ReconnectTabCommand}"
CommandParameter="{Binding SelectedTab}"
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
IsVisible="{Binding SelectedTab.IsFailed}" />
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
@@ -0,0 +1,62 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="DodoSSH.Client.App.Views.DevicePinWindow"
Width="420" SizeToContent="Height"
CanResize="False" ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Surface}"
Title="DodoSSH">
<!--
The PIN that unseals this machine's device key, on Linux and nowhere else.
◆ A WINDOW, WHERE EVERY OTHER QUESTION THIS APPLICATION ASKS IS A CARD.
ConfirmDeleteCard, HostKeyCard and the rest are overlays bound to view-model state, and that is the
right shape for a question about the vault: the shell owns the moment and the Android head shows the
same one. This is not that kind of question. It belongs to a platform integration — LinuxDeviceKeyStore
— and the shell it would otherwise live in is shared with the phone, which has a fingerprint and must
never grow a PIN box. Keeping it here keeps DodoSSH.Client.Shell platform-neutral, which is the
property IDeviceKeyStore exists to protect.
◆ AND IT IS DELIBERATELY PLAIN.
Windows and macOS draw their equivalent themselves — a secure desktop and a system sheet — and this one
cannot be either. Dressing it up to look like a system dialog would be teaching people that a
convincing DodoSSH PIN box is a system dialog, which is the phishing this platform's design cannot
prevent and should not rehearse. It says what it is, it names the application, and it stops there.
ADR 0007 records the difference rather than smoothing it over.
-->
<StackPanel Margin="20" Spacing="12">
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
x:Name="Explanation" />
<!--
RevealPassword is off and there is no eye button. A PIN is short enough to retype and this box is
open on a screen somebody may be sharing.
-->
<TextBox x:Name="Pin" PasswordChar="•" PlaceholderText="PIN" FontSize="14" />
<!--
Only when choosing. A mistyped PIN at registration is not recoverable in any useful sense: the key is
sealed to it, and the only way back is to withdraw the device and register again. Asking twice costs
one box.
-->
<TextBox x:Name="Confirm" PasswordChar="•" PlaceholderText="PIN again" FontSize="14"
IsVisible="False" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
x:Name="Problem" IsVisible="False" />
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button x:Name="Cancel" Content="CANCEL" />
<Button x:Name="Accept" Classes="accent" Content="UNLOCK" />
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,203 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Asks for the PIN that unseals this machine's device key.
/// </summary>
/// <remarks>
/// <para>
/// Raised by <see cref="LinuxDeviceKeyStore"/> through the delegate <c>App.axaml.cs</c> supplies, and by
/// nothing else. See the markup for why this is a window rather than a card, and why it is plain.
/// </para>
/// <para>
/// <b>Cancelling is an ordinary answer, not an error.</b> Escape, the close button and CANCEL all resolve
/// to null, which the store reads as "no PIN" and the unlock path answers with the passphrase screen. That
/// is the same contract <c>IDeviceKeyStore.TryLoadAsync</c> documents for a declined fingerprint.
/// </para>
/// </remarks>
public sealed partial class DevicePinWindow : Window
{
/// <summary>
/// The shortest PIN this will accept, and the reason it is this short rather than longer.
/// </summary>
/// <remarks>
/// Four, because the TPM counts wrong answers and stops — which is exactly the property that makes a
/// short secret safe here and does not exist for anything kept in a file. A PIN long enough to be safe
/// without hardware counting would be a second passphrase, and somebody asked for a second passphrase
/// reuses the first.
/// </remarks>
private const int ShortestPin = 4;
private TaskCompletionSource<string?>? answer;
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
public DevicePinWindow()
{
AvaloniaXamlLoader.Load(this);
Accept.Click += OnAccept;
Cancel.Click += OnCancel;
KeyDown += OnKeyDown;
Pin.KeyDown += OnKeyDown;
Confirm.KeyDown += OnKeyDown;
}
/// <summary>
/// Shows the prompt over the main window and answers with what was typed, or null.
/// </summary>
/// <remarks>
/// <para>
/// <b>Marshalled onto the UI thread here rather than at the call site.</b> The store is called from
/// wherever the unlock happens to be running — a background task on launch, a command handler on a
/// button — and none of those should have to know that this ends in a window. Avalonia throws rather
/// than misbehaving if it is wrong, which would turn a device unlock into a crash.
/// </para>
/// <para>
/// A missing owner answers null rather than showing an ownerless window. It means the main window is
/// gone or not up yet, and a modal dialog with nothing behind it is a prompt the user cannot place —
/// which is precisely the shape of the thing this application must not train people to accept.
/// </para>
/// </remarks>
/// <param name="owner">The window to sit over.</param>
/// <param name="purpose">Whether a PIN is being chosen or entered.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static ValueTask<string?> AskAsync(
Func<Window?> owner,
DevicePinPurpose purpose,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(owner);
return new ValueTask<string?>(
Dispatcher.UIThread.InvokeAsync(
async () =>
{
if (owner() is not { } parent)
{
return null;
}
var window = new DevicePinWindow();
window.Prepare(purpose);
using var registration = cancellationToken.Register(window.Close);
return await window.ShowFor(parent).ConfigureAwait(true);
}));
}
private void Prepare(DevicePinPurpose purpose)
{
var choosing = purpose == DevicePinPurpose.Choosing;
Heading.Text = choosing ? "Choose a PIN for this computer" : "Enter your DodoSSH PIN";
Explanation.Text = choosing
? "Your device key is sealed into this computer's TPM. The PIN is checked by the TPM itself, "
+ "which stops answering after a few wrong tries — so a short one is enough, and it only "
+ "ever works on this computer."
: "This unlocks the key sealed into this computer's TPM. Cancel to use your passphrase "
+ "instead.";
Confirm.IsVisible = choosing;
Accept.Content = choosing ? "SET PIN" : "UNLOCK";
}
private async Task<string?> ShowFor(Window parent)
{
answer = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
// Resolves the pending answer however the window goes away — the close button, Escape, or the
// cancellation registration above. Without it a closed window leaves an await that never returns,
// and the unlock path hangs rather than falling back to the passphrase.
Closed += (_, _) => answer.TrySetResult(null);
Pin.Focus();
await ShowDialog(parent).ConfigureAwait(true);
return await answer.Task.ConfigureAwait(true);
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Escape:
Complete(null);
e.Handled = true;
break;
case Key.Enter:
Submit();
e.Handled = true;
break;
default:
break;
}
}
private void OnAccept(object? sender, RoutedEventArgs e) => Submit();
private void OnCancel(object? sender, RoutedEventArgs e) => Complete(null);
/// <summary>
/// Checks what was typed and either answers with it or says what is wrong.
/// </summary>
/// <remarks>
/// Both checks are about a PIN being <em>chosen</em>. Nothing validates a PIN being entered, because
/// the only thing that can judge that is the TPM — refusing a four-character entry here because the
/// stored one was six would be this process deciding something it has no way to know.
/// </remarks>
private void Submit()
{
var pin = Pin.Text ?? string.Empty;
if (!Confirm.IsVisible)
{
if (pin.Length > 0)
{
Complete(pin);
}
return;
}
if (pin.Length < ShortestPin)
{
Show($"A PIN needs at least {ShortestPin} characters.");
return;
}
if (!string.Equals(pin, Confirm.Text ?? string.Empty, StringComparison.Ordinal))
{
Show("Those two do not match.");
return;
}
Complete(pin);
}
private void Show(string problem)
{
Problem.Text = problem;
Problem.IsVisible = true;
}
private void Complete(string? pin)
{
answer?.TrySetResult(pin);
Close();
}
}
@@ -0,0 +1,360 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:import="using:DodoSSH.Client.Import"
x:Class="DodoSSH.Client.App.Views.HetznerImportScreen"
x:DataType="vm:HetznerImportViewModel">
<!--
Importing the servers in a Hetzner Cloud project.
Drawn over the Preferences page inside settings mode, exactly as ImportScreen.axaml is — SettingsNav
stays lit on Preferences, the titlebar says "Back to preferences", and neither is reachable from the nav
rail. See MainWindowViewModel.IsHetznerImportOpen.
── WHY THIS ONE IS A WIZARD AND THE ssh_config IMPORTER IS NOT ────────────────────────────────────────
That screen reads a file that is already on this machine, so it can scan the moment it opens and show a
list. This one cannot: it needs a credential that has to be made in somebody else's console first, and
then it makes a request over the internet. Those are two separate moments and the screen is honest about
it — step one asks for the token, step two shows what the project holds, step three says what was stored.
Both of the first two write nothing.
── THE TOKEN IS NOT STORED ────────────────────────────────────────────────────────────────────────────
It is used for one request and cleared, whether the read succeeded or not. Nothing here writes it to the
vault, to settings or to a log, and the next import asks again — the same argument ImportScreen's key
tick makes for not remembering a "yes". The note above the box says which permission is needed (Read),
that a token is one project rather than one account, and that dedicated servers behind Hetzner's Robot
are not covered by any of this.
── NOTHING IMPORTED HERE CONNECTS ON ITS OWN ──────────────────────────────────────────────────────────
Hetzner has never held the private half of an SSH key, so no key can come with these hosts. That is said
above the button rather than after it, on the same reasoning ImportScreen.axaml gives for its own version
of this sentence: somebody who is not told will conclude the import was broken.
-->
<UserControl.Styles>
<!-- Matching ImportScreen's table exactly: this is the same table about a different source. -->
<Style Selector="Border.importrow:pointerover">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="Border.meaningchip.new">
<Setter Property="Background" Value="{StaticResource LiveWash}" />
</Style>
<Style Selector="Border.meaningchip.new > TextBlock">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="Border.meaningchip.exists">
<Setter Property="Background" Value="{StaticResource Chip}" />
</Style>
<Style Selector="Border.meaningchip.exists > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Border.meaningchip.warn">
<Setter Property="Background" Value="{StaticResource WarnWash}" />
</Style>
<Style Selector="Border.meaningchip.warn > TextBlock">
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
</Style>
</UserControl.Styles>
<Grid MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch" RowDefinitions="Auto,Auto,*">
<!-- ============ HEADER ============ -->
<StackPanel Grid.Row="0" Spacing="10">
<TextBlock Classes="settingstitle" Text="Import from Hetzner Cloud" />
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="label" Text="{Binding StepLabel}" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding StepTitle}" FontSize="12"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="0,12,0,0"
TextWrapping="Wrap" />
<Panel Grid.Row="2" Margin="0,18,0,0">
<!-- ============ STEP 1 · THE TOKEN ============ -->
<Grid IsVisible="{Binding IsTokenStep}" RowDefinitions="Auto,*">
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
<StackPanel Spacing="14">
<TextBlock Text="Cloud API token" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="{Binding TokenNote}" />
<TextBox Text="{Binding Token}" PasswordChar="•" Height="42" FontSize="13"
FontFamily="{StaticResource MonoFont}"
PlaceholderText="64 characters, from Security → API tokens">
<!-- Enter reads the project, so a pasted token does not need the mouse to go anywhere. -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}" />
</TextBox.KeyBindings>
</TextBox>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Classes="ghost" Height="42" Content="CANCEL" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences. Nothing has been sent." />
<Button Classes="accent" Height="42" Content="READ THE PROJECT"
Command="{Binding ScanCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Lists the project's servers. Nothing is stored, and the token is forgotten as soon as the list arrives." />
</StackPanel>
</StackPanel>
</Border>
</Grid>
<!-- ============ STEP 2 · WHAT THE PROJECT HOLDS ============ -->
<Grid IsVisible="{Binding IsChooseStep}" RowDefinitions="Auto,Auto,*,Auto">
<!--
What every imported host gets: which address, which login user, which port. One set of answers for
the whole import rather than one per row, because an estate is reached the same way throughout —
a per-server dropdown would be forty answers to one question.
-->
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="20,18">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
<TextBlock Grid.Column="0" Classes="label" Text="DIAL" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<!--
Chips rather than radio buttons: this application has no RadioButton style, and Fluent's
own beside a row of chips reads as belonging to a different program. Button.chiptoggle is
the tag picker's control, which is already "one of these is on" drawn as a chip.
-->
<Button Classes="chiptoggle" Content="PUBLIC IPv4"
Classes.worn="{Binding UsesPublicIPv4}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv4}" />
<Button Classes="chiptoggle" Content="PUBLIC IPv6"
Classes.worn="{Binding UsesPublicIPv6}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv6}" />
<Button Classes="chiptoggle" Content="PRIVATE NETWORK"
Classes.worn="{Binding UsesPrivate}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.Private}" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="label" Text="USER" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" Margin="14,0,0,0" />
<TextBox Grid.Column="3" Text="{Binding Username}" Width="120" Height="34"
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
ToolTip.Tip="Every Hetzner Linux image puts the project's SSH keys on root and creates no other account." />
<TextBox Grid.Column="4" Text="{Binding PortText}" Width="66" Height="34"
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
ToolTip.Tip="The SSH port every imported host gets." />
</Grid>
<!--
◆ WHERE THEY LAND, AND WHAT THEY AUTHENTICATE WITH. The two answers this import used to
decide on somebody's behalf: the active vault, and nothing.
A project belongs to a team more often than to a person, so the vault is asked rather than
assumed — see HetznerImportViewModel.VaultChoices, and the host editor's own picker for why
this is its own selection rather than the keychain screen's standing preference. It is hidden
at one vault, which is a question with one answer.
The key is the half Hetzner cannot supply and the keychain usually already has: the public
half went to Hetzner when the servers were made, the private half never did. Pointing forty
hosts at it here is one answer; without it, it was forty visits to forty host editors. The
picker is hidden entirely on a keychain with no keys in it.
-->
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="18">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding HasVaultChoice}">
<TextBlock Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<ComboBox Width="230" Height="34" ItemsSource="{Binding VaultChoices}"
SelectedItem="{Binding SelectedVault}"
ToolTip.Tip="Which vault the imported hosts are written into. A shared one is readable by everybody who holds its key.">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding HasKeyChoice}">
<TextBlock Classes="label" Text="KEY" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<ComboBox Width="260" Height="34" ItemsSource="{Binding KeyChoices}"
SelectedItem="{Binding SelectedKey}"
ToolTip.Tip="A key already in your keychain, which every imported host is pointed at. Nothing is copied — the host stores the key's id, exactly as one bound by hand does.">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerKeyChoice">
<TextBlock Text="{Binding Label}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Grid>
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17" TextWrapping="Wrap"
Text="{Binding AddressNote}" />
<TextBlock Classes="hint" FontSize="11" IsVisible="{Binding !IsPortValid}"
Foreground="{StaticResource WarnText}"
Text="That is not a port. Give a number between 1 and 65535." />
</StackPanel>
</Border>
<!-- What the project could not be made to say, at project level. -->
<Border Grid.Row="1" Margin="0,14,0,0" Padding="14,10" CornerRadius="10"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" IsVisible="{Binding HasWarnings}">
<ItemsControl ItemsSource="{Binding Warnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11.5"
TextWrapping="Wrap" Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- ============ THE TABLE ============ -->
<Border Grid.Row="2" Margin="0,14,0,0" CornerRadius="12" Background="{StaticResource Pane}"
BorderBrush="{StaticResource Border}" BorderThickness="1" ClipToBounds="True"
IsVisible="{Binding HasRows}">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*">
<!-- A Button rather than a CheckBox, for the reason ImportScreen.axaml's own header gives. -->
<Button Grid.Column="0" Classes="flat" Width="18" Height="18" Padding="0"
VerticalAlignment="Center" Command="{Binding ToggleAllCommand}"
ToolTip.Tip="Tick or untick every row this address choice can reach">
<Panel Width="18" Height="18">
<Border CornerRadius="5" Background="{StaticResource Accent}"
IsVisible="{Binding AllTicked}">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5CA;" FontSize="13"
Foreground="White" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<Border CornerRadius="5" BorderBrush="{StaticResource BorderMid}" BorderThickness="1.5"
IsVisible="{Binding !AllTicked}" />
</Panel>
</Button>
<TextBlock Grid.Column="1" Classes="label" Text="SERVER" FontSize="9.5" LetterSpacing="1"
Margin="14,0,0,0" />
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="WHAT THIS MEANS" FontSize="9.5"
LetterSpacing="1" />
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerRowViewModel">
<Border Classes="importrow" CornerRadius="8">
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*" Margin="12" MinHeight="20">
<!--
Disabled where the address choice cannot reach this server, which is the private
case: a server on no private network is not importable and must not be silently
given a public address instead.
-->
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}"
IsEnabled="{Binding CanImport}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="14,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Kind}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Grid.Column="5" Classes="meaningchip" CornerRadius="5" Padding="8,3"
HorizontalAlignment="Left" VerticalAlignment="Center"
Classes.new="{Binding IsMeaningNew}"
Classes.exists="{Binding IsMeaningExisting}"
Classes.warn="{Binding IsMeaningWarned}" ToolTip.Tip="{Binding Meaning}">
<TextBlock Text="{Binding Meaning}" FontSize="10" FontWeight="SemiBold"
MaxWidth="230" TextTrimming="CharacterEllipsis" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
<!-- ============ FOOTER ============ -->
<StackPanel Grid.Row="3" Margin="0,16,0,0" Spacing="14">
<!--
Said before the button. Whether a credential comes with the host is the difference between a
bookmark that connects and one that asks for a password, and nothing about a Hetzner project can
supply one.
-->
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="{Binding AuthenticationNote}" />
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="hint" FontSize="12" VerticalAlignment="Center"
Text="{Binding SelectionSummary}" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN"
Command="{Binding RestartCommand}"
ToolTip.Tip="Back to the token box, for another project. This list goes with it — the token it came from was not kept." />
<Button Classes="ghost" Height="44" Content="CANCEL" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences. Nothing is stored." />
<Button Classes="accent" Height="44" Content="{Binding ImportLabel}"
Command="{Binding ImportCommand}" IsEnabled="{Binding CanImport}" />
</StackPanel>
</Grid>
</StackPanel>
</Grid>
<!-- ============ STEP 3 · WHAT WAS STORED ============ -->
<Grid IsVisible="{Binding IsDoneStep}" RowDefinitions="Auto,*,Auto">
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
<StackPanel Spacing="12">
<TextBlock Text="What happens next" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="{Binding AuthenticationNote}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="The list this came from is one press away. Going back to it and importing the rest costs nothing — what was just stored is marked as already here. It is the list this scan produced, though, not a fresh one: a server made in the Hetzner console since then is not in it." />
</StackPanel>
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right"
Margin="0,16,0,0">
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN" Command="{Binding RestartCommand}"
ToolTip.Tip="Back to the token box, for another project." />
<Button Classes="ghost" Height="44" Content="BACK TO THE LIST"
Command="{Binding ReviewCommand}"
ToolTip.Tip="The same servers, with what you just stored marked as already here." />
<Button Classes="accent" Height="44" Content="DONE" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences." />
</StackPanel>
</Grid>
</Panel>
</Grid>
</UserControl>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Importing hosts from a Hetzner Cloud project.
/// </summary>
/// <remarks>
/// <para>
/// A task rather than a destination, reached from Preferences and not from the nav rail, and drawn inside
/// settings mode over the Preferences page — the same shape <see cref="ImportScreen"/> has, for the same
/// reasons, including having no keyboard target of its own to hand back.
/// </para>
/// <para>
/// No code behind it, unlike <see cref="ImportScreen"/>, and the difference is worth a sentence: that screen
/// listens for bubbled tick events to keep its footer count honest, because its rows have no way to reach the
/// list they are in. This one's view model subscribes to each row's own change notification instead — see
/// <c>HetznerImportViewModel.OnRowChanged</c> — which puts the same wiring where the count actually lives
/// rather than in a routed-event handler on a control.
/// </para>
/// </remarks>
internal sealed partial class HetznerImportScreen : UserControl
{
public HetznerImportScreen() => InitializeComponent();
}
+9 -16
View File
@@ -636,16 +636,15 @@
<!--
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
exactly what it was, "(not wired up yet)" included: the design's own wording implies a relay
this client can dial, and it cannot. See the remark under the hint below, which is unchanged
and still the reason this box says what it says.
title beside it rather than under it. The "(not wired up yet)" this title carried while the
client had no path to the relay is gone, because it has one: a relayed host is connected
through a ticket, a WebSocket and a loopback bridge. See docs/reaching-a-host-you-cannot-dial.md.
-->
<Border CornerRadius="12" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
<StackPanel Spacing="6">
<CheckBox IsChecked="{Binding EditorRelayEnabled}" VerticalContentAlignment="Center">
<TextBlock Text="Connect through the server relay (not wired up yet)" FontSize="13"
<TextBlock Text="Connect through the server relay" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
</CheckBox>
@@ -655,19 +654,13 @@
to resolve the target, or it becomes an authenticated open proxy into the operator's
network.
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay
is built and this client has no path to it: VaultViewModel dials the address directly
whether this is ticked or not. So the box as it stood spent the one deliberate privacy
concession in the design — the address in plaintext columns — and delivered a
connection that failed exactly as it had before, with nothing saying why.
Left tickable rather than disabled, because a host that already carries the flag has to
be able to lose it, and a disabled control would trap the concession on. See
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
removable.
It also says the two things a user would otherwise find out by failing: the server,
not this machine, has to be able to reach the address — the relay is no help against a
deployment outside the target's network — and a relayed host does not fall back to
dialling directly, so it connects only while the server is reachable.
-->
<TextBlock Classes="hint" FontSize="11.5"
Text="Not built yet: this client 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 does work, the relay will dial on your behalf — which is why the address and port have to be stored in plain text. Everything else about the host stays encrypted either way." />
Text="The server dials this host on your behalf, for a machine this computer 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 plain text; everything else about the host stays encrypted. A relayed host connects only while you are online." />
</StackPanel>
</Border>
+52 -8
View File
@@ -457,11 +457,12 @@
something that refuses it, leaving the window with nothing focused and the keystrokes going
nowhere.
◆ EVERY GROUP, ONE AFTER ANOTHER, "No group" FIRST. Binds HostSections rather than VisibleHosts:
the old grid held one level of a tree at a time, and this holds every group's cards at once, the
way SidebarRows always has for the phone. A keychain nobody has filed anything in draws one
section with no heading at all — see HostSectionViewModel.HasHeader — which is what makes the
feature invisible until it is used.
◆ EVERY VAULT, AND INSIDE EACH ONE EVERY GROUP, "No group" FIRST. Binds HostSections rather
than VisibleHosts: the old grid held one level of a tree at a time, and this holds every group's
cards at once, the way SidebarRows always has for the phone. A keychain nobody has filed
anything in draws one section with no heading at all — see HostSectionViewModel.HasHeader —
which is what makes the feature invisible until it is used. A session holding one vault draws no
vault headings either, for the same reason and by the same rule.
-->
<ItemsControl x:Name="Board" Focusable="True" ItemsSource="{Binding HostSections}">
<ItemsControl.ItemsPanel>
@@ -472,6 +473,27 @@
<DataTemplate x:DataType="vm:HostSectionViewModel">
<StackPanel Spacing="10">
<!--
============ THE VAULT HEADING ============
◆ THE BOARD'S OUTER LEVEL. A vault holds groups and a group holds hosts, and until this
row existed the board drew only the inner two — every group of every vault in one run,
told apart by a small grey vault name beside each heading. Two vaults each holding a
"production" were two headings with the same word on them.
Drawn on the first section of each vault and nowhere else — see
HostSectionViewModel.VaultHeading — and on no section at all where the board has one
vault, which is what keeps a single-vault keychain looking exactly as it did. The rule
to its right is the whole of the emphasis: this level is a divider with a name on it,
not a heading competing with the group headings under it.
-->
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding StartsAVault}" Margin="0,2,0,2">
<TextBlock Grid.Column="0" Text="{Binding VaultHeading}" FontSize="11.5"
FontWeight="Bold" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Height="1" Margin="10,0,0,0" VerticalAlignment="Center"
Background="{StaticResource BorderSubtle}" />
</Grid>
<!--
============ THE SECTION HEADING ============
"{label} · {count}", the vault name beside it when more than one vault is in play, a
@@ -569,6 +591,18 @@
Command="{Binding #Board.((vm:VaultViewModel)DataContext).OpenHostPaneCommand}" />
<MenuItem Header="Edit…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).EditSelectedHostCommand}" />
<!--
◆ THE SAME ENTRY THE DRAWER'S ⋯ MENU HAS, on the board where the host is. Moving
one machine used to mean opening its pane first and then finding the menu inside
it, while moving six was a right click away — the one-host case was the harder of
the two, which is backwards. The command raises the drawer itself (see
VaultViewModel.MoveHost); the picker it opens lives in that footer and nowhere
else, and an entry that armed a panel nobody could see would be a menu item that
did nothing.
-->
<MenuItem Header="Move to another vault…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).MoveHostCommand}" />
<Separator IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" />
<MenuItem Header="Delete…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).DeleteHostCommand}" />
@@ -600,16 +634,26 @@
The full address is on the tooltip and nowhere else on the card — the design's own
rule, kept: a card read while scanning a section is read for its name and whether
it is live, and an address is what somebody reads once they have found the machine,
which the tooltip and the drawer both still say.
which the tooltip and the drawer both still say. The name is on the tooltip too,
and that is not duplication of the line six pixels above it: 214 pixels of card
trims it, and the tooltip is where the untrimmed one lives. See
HostRowViewModel.Tooltip.
MinHeight rather than a fixed Height, unlike the mock's own 120: this codebase's
Border.tile is deliberately a fixed width and a free height (see App.axaml), so a
card wearing four tags grows a second line rather than clipping the fourth one — a
silent truncation is exactly the kind of thing the honesty rule in
HANDOFF-hosts-v5.md exists to catch, and it costs nothing here to avoid.
◆ 104 RATHER THAN THE MOCK'S 120, and the floor is the only thing that moved. What
a card with no tags actually needs is about 87 — 20 of padding, 43 of name and
address, 24 for the row along the bottom — so 120 was thirty pixels of air below
every card on a board that is scrolled. It is still a floor with room in it: the
number is deliberately above what the content measures, so the common card is a
uniform height rather than each one shrink-wrapping its own last line.
-->
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="120"
ToolTip.Tip="{Binding Address}">
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="104"
ToolTip.Tip="{Binding Tooltip}">
<Panel>
<Grid RowDefinitions="Auto,*,Auto">
@@ -263,7 +263,9 @@ internal sealed partial class HostsScreen : UserControl
if (control)
{
vault.ToggleHostChoiceCommand.Execute(row);
// Extend rather than toggle: the set this starts has the lit card in it, which is the half a
// plain toggle left out. See VaultViewModel.ExtendHostChoice.
vault.ExtendHostChoiceCommand.Execute(row);
anchor = row;
Board.Focus();
+22 -4
View File
@@ -156,9 +156,19 @@
either: TransfersScreen draws that same string itself, both in its own empty state and
beside the remote pane's DISCONNECT once something is open.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto">
<!--
Three rows since the disconnect strip arrived, and it is the same control the terminal
surface draws — see SessionEndedBar.axaml, which resolves which surface it is about
through the shell rather than through a binding of its own. Auto and empty except while
the file session has been lost, so an ordinary browse pays nothing for it.
Note the DataContext: this row is the shell's rather than the transfers screen's, which
is why the strip sits here and not inside TransfersScreen.axaml.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
<views:SessionStatusBar Grid.Row="1" />
<views:SessionEndedBar Grid.Row="1" />
<views:SessionStatusBar Grid.Row="2" />
</Grid>
<!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
@@ -279,7 +289,14 @@
this Grid is only drawn on the terminal surface, and the surface with no tab open already
answers for itself in the tab row's own "+" and in the connecting card below.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto">
<!--
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
and empty except while the selected tab's shell has stopped, so it costs the terminal
nothing in the ordinary case — and when it is up it takes its 34 pixels off the pane rather
than drawing over it, which is the only way Avalonia content can sit near the renderer at
all. See SessionEndedBar.axaml.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
<Panel Grid.Row="0" Background="{StaticResource Pane}">
@@ -306,7 +323,8 @@
</Panel>
<views:SessionStatusBar Grid.Row="1" ShowsEncoding="True" />
<views:SessionEndedBar Grid.Row="1" />
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
</Grid>
<!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
+17 -30
View File
@@ -252,13 +252,23 @@
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
<!--
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
see MainWindowViewModel.EnterSettings and SettingsView.axaml. Settings opens on General, the
mode's own default landing page; Vaults and Preferences open directly on the page they name,
which is also what anything that used to navigate to ShellScreen.Vaults or
ShellScreen.Preferences now does — see ShowScreen. Three handlers rather than the two rows
sharing one before this wave: the mock's Settings area was a family of screens that did not
exist yet, and now that it does, "Settings" and "Preferences" are no longer the same click.
◆ ONE ROW, WHERE v5c PUT THREE.
Settings, Vaults and Preferences all opened the same full-window settings mode — see
MainWindowViewModel.EnterSettings and SettingsView.axaml — differing only in which of its
seven pages they landed on. Two of the three were therefore deep links into a place the third
one opens, and SettingsNav is already on screen the whole time listing every page including
those two, so the shortcut saved one click and cost a menu that read as though it offered
three destinations.
It was three rows because settings mode had only just been built out of what used to be two
bare ShellScreens: "Settings" and "Preferences" were the same click before that wave, and
splitting them was the fix for a real thing. Folding the other two back in is not a return to
that state — Vaults and Preferences are pages now, reached from the rail inside settings, and
what this menu is for is getting there at all.
What still lands on a page rather than on General is Logout below, and it has to: its
confirmation card is drawn inline on the Account page and nowhere else.
-->
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
@@ -268,29 +278,6 @@
</StackPanel>
</Button>
<!--
◆ THE SAME TREATMENT AS SETTINGS ABOVE AND LOGOUT BELOW, which these two did not have: their
label was TextGhost where the other two rows' was Text, so a menu of five equally live
destinations drew two of them in the colour this window uses for something switched off. The
icons stay one step quieter than the words — the idiom the nav rail's own rows already follow
— but "quieter than the word beside it" and "dimmed" are not the same statement.
-->
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE84F;" FontSize="12"
Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel>
</Button>
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE429;" FontSize="12"
Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel>
</Button>
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
<!--
@@ -59,28 +59,6 @@ internal sealed partial class NavRail : UserControl
}
}
/// <summary>Opens settings mode on its Preferences page.</summary>
private void OnPopoverPreferencesPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
}
}
/// <summary>Opens settings mode on its Vaults page.</summary>
private void OnPopoverVaultsPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.EnterSettingsCommand.Execute(SettingsPage.Vaults);
}
}
/// <summary>
/// Starts a sign-out, through the Account settings page so the confirmation card has somewhere to be
/// seen — see <see cref="MainWindowViewModel.SignOutFromPopover"/>.
@@ -0,0 +1,106 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.SessionEndedBar"
x:DataType="vm:MainWindowViewModel">
<!--
── What a session says once it has stopped ──────────────────────────────────────────────────────────
A strip between the pane and the status bar, shown only while the session in front of the user has
ended. It names which ending it was, prints the sentence that came with it, and offers the one thing
worth offering: dialling the machine again.
◆ ONE CONTROL ON BOTH SURFACES, and the shell is what resolves which it is drawn over. A terminal has
two endings to tell apart and a keychain entry to check before it can offer a redial; a file session has
one ending and already captured what it would dial. None of that is a decision a view should be making,
so every binding here is a MainWindowViewModel property that has already asked which surface is showing
— see SessionEndedText, CanReconnectSession and SessionEndedDismissLabel. The second button is even
labelled from the shell, because CLOSE TAB and DISCONNECT are noticeably different acts and one vague
word covering both would be the wrong kind of reuse.
◆ IT IS A ROW OF ITS OWN RATHER THAN SOMETHING DRAWN OVER THE PANE, and on the terminal surface that is
not a style choice. The renderer is a native child view that composites above everything Avalonia draws,
so a banner overlapping its rectangle would either be invisible or would have to collapse the terminal —
and collapsing it is exactly wrong here, because the scrollback is what the user is reading. A dead
shell's last words are usually why it died. So this takes its own Auto row and the pane simply gets 34
fewer pixels while it is up. The file surface has no such constraint and takes the same row anyway,
because a listing left on screen is worth the same as a scrollback: it is where the user was, and it is
what makes RECONNECT mean something. See MainWindow.axaml for both placements.
◆ THE SENTENCE IS HERE AS WELL AS IN THE PANE ON PURPOSE. terminal.js writes the same string into the
scrollback as a grey rule, which is right and is not enough: a machine that printed a page of shutdown
messages on its way down has pushed that line off the screen, and a session that ended while the user
was on another tab was never on screen at all. This copy does not scroll.
── the two endings, and why they are not one colour ─────────────────────────────────────────────────
SHELL EXITED is grey. Somebody typed `exit`, or a login shell ended; nothing is wrong and an alarm
would be noise. DISCONNECTED is the palette's red, because the connection went out from under a session
that had not finished — a reboot, a network drop, an sshd that was killed — and it is the one of the two
that means the machine may still be there and worth dialling. The dot beside the word says the same
thing a second way, for anybody who cannot tell this design's red from its grey.
── the buttons ──────────────────────────────────────────────────────────────────────────────────────
RECONNECT is disabled rather than hidden when there is nothing to dial — a tab typed in by hand, or one
whose host has left the keychain — because the answer to "why can I not reconnect this" is better given
by a control that is visibly refusing than by one that is not there. See
MainWindowViewModel.CanReconnectSession, which is what draws that line on each surface, and
ReconnectSessionAsync for what the button actually does: each surface's own connect path, back into the
thing that stopped. On the file side that includes walking back to the directory the pane was showing,
which is what makes it a reconnect rather than a connect.
The second button is the moment the other reasonable answer is "I have read it, take it away" — and
hunting for a 16-pixel cross in the strip above is not the shape of that answer.
-->
<UserControl.Styles>
<Style Selector="TextBlock.endword">
<Setter Property="FontFamily" Value="Montserrat" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="9.5" />
<Setter Property="LetterSpacing" Value="1.2" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="TextBlock.endword.dropped">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Style>
</UserControl.Styles>
<Border Height="34" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Classes="dot" Width="7" Height="7" Classes.dropped="{Binding IsSessionDropped}"
VerticalAlignment="Center" />
<TextBlock Classes="endword" Classes.dropped="{Binding IsSessionDropped}"
Text="{Binding SessionEndedText}" />
</StackPanel>
<!--
Trimmed rather than wrapped: this row is one line tall and the sentences it draws are short, but a
transport error can hand over whatever the operating system called it, and a bar that grew to fit
one would take the height out of the terminal above it.
-->
<SelectableTextBlock Grid.Column="1" Classes="mono" FontSize="11.5" Margin="16,0"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
Text="{Binding SessionEndedDetail}"
ToolTip.Tip="{Binding SessionEndedDetail}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<Button Classes="ghost" Content="RECONNECT"
Command="{Binding ReconnectSessionCommand}"
IsEnabled="{Binding CanReconnectSession, FallbackValue=False}"
ToolTip.Tip="Opens this connection again, in place." />
<Button Classes="ghost" Content="{Binding SessionEndedDismissLabel}"
Command="{Binding DismissEndedSessionCommand}" />
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>What the session shell says once the session in front of the user has stopped.</summary>
/// <remarks>
/// No code of its own: the word, the sentence and both buttons are the shell's, including which surface
/// they are about — see the markup. In its own file for the reason every other card here is: nothing inside
/// <c>MainWindow</c> can be laid out by a test, because WebView2's adapter refuses the headless session's
/// thread.
/// </remarks>
internal sealed partial class SessionEndedBar : UserControl
{
public SessionEndedBar() => InitializeComponent();
}
@@ -70,12 +70,25 @@
The address, and the button that closes the column. Both on one row, and the address is the
trimming one: a long account@host:port is exactly the string that would otherwise push the
close button off the edge of a panel whose whole point is that it can be got rid of.
SelectableTextBlock rather than TextBlock, which is what the rest of this application already
reaches for whenever a string is on screen to be taken somewhere else — a fingerprint, a public
key, a recovery code. This is the one place the shell prints the endpoint it actually dialled,
and the endpoint is a thing people paste: into another client, into a ticket, into the ssh
command they are writing beside this window. Retyping it off the screen is how a digit gets
dropped from an address that is mostly digits.
The trimming and the selection do disagree at the long end, and the ToolTip is why the
disagreement is tolerable rather than a trap: a drag reaches only the glyphs that were drawn,
so an address wearing an ellipsis has to be taken with select-all — Ctrl+A, then Ctrl+C — and
the hover already shows what the full string is, so nobody is copying half of one without
having been told there is a half.
-->
<Grid ColumnDefinitions="*,Auto" Margin="8,0,0,0">
<TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
FontSize="12.5" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}" />
<SelectableTextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
FontSize="12.5" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}" />
<Button Grid.Column="1" Classes="flat sidebargrip"
Command="{Binding ToggleSessionSidebarCommand}"
ToolTip.Tip="Close this column. The terminal takes the width, and the rail it leaves behind brings it back.">
@@ -42,14 +42,29 @@
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
Classes.dropped="{Binding IsSessionDropped}"
VerticalAlignment="Center" />
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
IsVisible="{Binding IsSessionConnected}" />
<!--
Three words where there were two, and the third replaces rather than joins the second — see
MainWindowViewModel.IsSessionIdle, which is what keeps them from ever being drawn together.
NOT CONNECTED is honest about a tab still dialling and useless about one whose shell has
stopped: it says nothing about which of the two endings it was, and the difference between them
is the difference between work that is finished and a machine that stopped answering. The strip
below carries the sentence and the way back; this is the same fact in the one line that is
always on screen.
-->
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
IsVisible="{Binding !IsSessionConnected}" />
IsVisible="{Binding IsSessionIdle}" />
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="{Binding SessionEndedText}"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
@@ -74,9 +74,17 @@
Worth the third colour because the two it replaces were the same one: a tab still
dialling and a tab whose shell has exited both drew grey, which are the two states in
this strip with the least in common — one is worth waiting for and the other is over.
── and a fourth since, splitting that grey ────────────────────────────────────────────
A shell that ended and a connection that broke were still one dot, and they are the two
dead states with the least in common: one is work finished, the other is a machine that
stopped answering and may well still be there. Red for the second, matching the strip
under the terminal — see TerminalTabViewModel.IsDropped and SessionEndedBar.axaml, which
is where the sentence and the way back live.
-->
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
Classes.connecting="{Binding IsConnecting}"
Classes.dropped="{Binding IsDropped}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
@@ -47,7 +47,7 @@
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Check on its own" />
<TextBlock Classes="settingsrowcaption"
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
Text="Every six hours while DodoSSH is running, starting at launch. It keeps checking while the keychain is locked — where builds come from has nothing to do with your vault — and while an update is already waiting, so what is offered is the newest build rather than the first one this run happened to see." />
</StackPanel>
<CheckBox Grid.Column="1" VerticalAlignment="Top"
IsChecked="{Binding Updates.IsAutomatic}"
@@ -15,8 +15,10 @@
TERMINAL keeps its one real setting, text size, and drops the design's other five — font, cursor,
scrollback, copy on select, terminal bell — which the renderer hard-codes. KEYCHAIN keeps Lock now,
Sync now/Sign in, and the importer row exactly as the old screen had them; the design's own SECURITY
card (clipboard-clear delay, confirm-run-on-insert) has nothing behind either row and is not drawn here.
Sync now/Sign in, and the importer rows; the design's own SECURITY card (clipboard-clear delay,
confirm-run-on-insert) has nothing behind either row and is not drawn here.
Two importer rows now rather than the one the old screen had: ~/.ssh/config, and a Hetzner Cloud project.
-->
<ScrollViewer>
@@ -95,7 +97,7 @@
</Grid>
</Border>
<Border Classes="settingsrow last">
<Border Classes="settingsrow">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Import SSH config" />
@@ -108,6 +110,24 @@
</Grid>
</Border>
<!--
The second importer, beside the first rather than folded into it: they read different things
through different code, and one row that asked "from where?" would put a question in front of
the far more common answer. See HetznerImportScreen.axaml.
-->
<Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Import from Hetzner Cloud" />
<TextBlock Classes="settingsrowcaption"
Text="Lists the servers in one Hetzner Cloud project and imports the ones you tick. Needs a read-only API token, which is used for a single request and never stored; a token belongs to one project, so an account with several runs this once each. No key can come with them — Hetzner has never held the private half of one — so imported hosts ask for a password until you bind them to a key in your keychain. Dedicated servers behind Hetzner's Robot are not covered." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN WIZARD"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.HetznerImport}" />
</Grid>
</Border>
</StackPanel>
</Border>
@@ -17,9 +17,12 @@
bars needs its own window behaviour a shared base would have to be pulled apart first.
v5c-3: two back buttons rather than one whose text and command a converter swaps, toggled by
MainWindowViewModel.IsImportOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in
place of "Back to application" while the importer is up, and CloseImportCommand closes it without leaving
MainWindowViewModel.IsImporterOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in
place of "Back to application" while an importer is up, and CloseImportCommand closes it without leaving
settings mode, unlike LeaveSettingsCommand.
IsImporterOpen rather than IsImportOpen because there are two importers now — the ssh_config one and the
Hetzner wizard — and this bar behaves identically for both: same label, same destination, same command.
-->
<Border Height="53" Background="{StaticResource DeepChrome}"
@@ -28,7 +31,7 @@
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
IsVisible="{Binding !IsImportOpen}"
IsVisible="{Binding !IsImporterOpen}"
Command="{Binding LeaveSettingsCommand}"
ToolTip.Tip="Back to the screen you were on before opening Settings">
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
@@ -40,7 +43,7 @@
</Button>
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
IsVisible="{Binding IsImportOpen}"
IsVisible="{Binding IsImporterOpen}"
Command="{Binding CloseImportCommand}"
ToolTip.Tip="Back to preferences, without leaving Settings">
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
@@ -23,6 +23,11 @@
because ActiveSettingsPage never actually leaves SettingsPage.Preferences; only IsImportOpen and the pair
of IsVisible bindings below move. Its data context is ImportScreen rather than this control's own, the
same split MainWindow.axaml drew before the importer moved in here.
HetznerImportScreen joined it on identical terms. Two flags rather than one "which importer" value,
because they are two independent facts about the content column and every path that opens either closes
the other; what the Preferences page below asks is IsSettingsPreferencesContentShowing, which is written
against both.
-->
<Grid RowDefinitions="Auto,*" Background="{StaticResource Canvas}">
@@ -59,6 +64,11 @@
<views:ImportScreen DataContext="{Binding ImportScreen}" />
</Panel>
<!-- The Hetzner wizard, on exactly the same terms. See MainWindowViewModel.IsHetznerImportOpen. -->
<Panel IsVisible="{Binding IsHetznerImportOpen}">
<views:HetznerImportScreen DataContext="{Binding HetznerImportScreen}" />
</Panel>
</Panel>
</Grid>
@@ -0,0 +1,54 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="DodoSSH.Client.App.Views.SplashCard">
<!--
What is on screen while the application composes itself.
── WHY THIS IS ITS OWN CONTROL ────────────────────────────────────────────────────────────────────────
The same reason UpdateBanner is: a Window cannot be laid out by the headless suite when it contains a
WebView, and while this one does not, a UserControl is what LayoutHarness can host and measure. See
SplashCardTests. It also keeps the window itself down to a frame and a size.
── AND WHY THERE IS NO VIEW MODEL ─────────────────────────────────────────────────────────────────────
There is nothing to observe. Composition is one synchronous block — see DodoSshApp.ComposeBehind — so
this control has exactly two states, both of them set by the one caller: starting, and could not. Two
methods on the code-behind is the whole of it, and a view model would be an observable object with a
lifetime and a test of its own standing in for two assignments.
-->
<Border Background="{StaticResource Chrome}"
BorderBrush="{StaticResource Border}"
BorderThickness="1">
<StackPanel Margin="28" Spacing="12" VerticalAlignment="Center">
<TextBlock Classes="heading" Text="DodoSSH" />
<TextBlock x:Name="Line" Classes="hint" Text="Starting…" />
<!--
── AND NO PROGRESS BAR, WHICH IS A REMOVAL RATHER THAN AN OMISSION ─────────────────────────────────
There was an indeterminate one here, and on screen it did not move. The reason is the same fact this
whole window exists for: composition is a synchronous block, and DodoSshApp posts it to the very
dispatcher that would have to animate the bar. Background priority buys the splash its first paint
and nothing after it — once composition starts, the UI thread is inside it until the main window is
ready, and an indeterminate bar's animation is driven by that thread's clock. So it painted one
frame and froze.
A frozen indeterminate bar does not read as "indeterminate". It reads as a determinate bar stuck at
zero, which is the one thing worse than no bar at all: it says the work has stalled at the exact
moment the application is busiest. Nothing here can honestly be measured either — a SQLite open, a
loopback bind, a TPM probe and an install-layout read report no progress between them.
So the line above is the whole of it. "Starting…" is true for as long as it is on screen, it needs
no clock to stay true, and the window closing is what says it finished.
-->
</StackPanel>
</Border>
</UserControl>
@@ -0,0 +1,29 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>What is on screen while the application composes itself.</summary>
/// <remarks>
/// The two methods below are the whole interface, and both are called by
/// <c>DodoSshApp.ComposeBehind</c> and by nothing else. See the control's own markup for why there is no
/// view model behind it.
/// </remarks>
internal sealed partial class SplashCard : UserControl
{
public SplashCard() => InitializeComponent();
/// <summary>Says what is happening, while it still is.</summary>
internal void Say(string what) => Line.Text = what;
/// <summary>
/// Says why nothing is going to happen, and stops pretending otherwise.
/// </summary>
/// <remarks>
/// The line is the whole message now that the progress bar has gone — see the markup for why it went.
/// It used to have to stop the bar as well: a composition that threw leaves this window as the only one
/// there is, with no main window to hand over to, so anything still claiming to be working would be
/// doing it over a process that has stopped. One assignment says it instead, and there is nothing left
/// on this card that could contradict it.
/// </remarks>
internal void CouldNotStart(string why) => Line.Text = why;
}
@@ -0,0 +1,49 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.SplashWindow"
Title="DodoSSH"
Icon="/Assets/dodossh.ico"
Width="420"
MinHeight="160"
SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{StaticResource Chrome}"
WindowDecorations="None">
<!--
── WHY THERE IS A SPLASH AT ALL ───────────────────────────────────────────────────────────────────────
The main window cannot be shown until it has a view model, and the view model cannot exist until
composition has opened the SQLite cache, bound the terminal's loopback socket, asked the platform about
a TPM and read the install layout off disk. Until this window existed, all of that happened with nothing
whatever on screen: the icon had been double-clicked and the desktop looked unchanged.
The main window's own IsStarting card already narrates what happens *after* it appears — see
MainWindow.axaml — and this is the part before that, which no arrangement of the main window can cover.
── NOT TOPMOST, AND NO TASKBAR SUPPRESSION ────────────────────────────────────────────────────────────
Both are the usual splash-screen habits and both are wrong here. Topmost means a start that stalls sits
over whatever the user gave up and went back to; hiding the taskbar entry means the one place they would
look to find out whether anything is happening says nothing. It is a window, briefly, and behaves like
one.
WindowDecorations="None" rather than the BorderOnly the main window asks for: this cannot be resized, has
no titlebar of its own to drag by, and lives for about a second. (The main window still says
SystemDecorations, which Avalonia 12 has deprecated in favour of this property; that one is not this
change's to move.) What replaces the frame is the card's
own one-pixel border. Escape closes it only once there is nothing left to wait for — see the code-behind.
── SizeToContent, WHICH IS ABOUT THE FAILURE AND NOT ABOUT THE SPLASH ─────────────────────────────────
A fixed height is right for the one line this normally shows and wrong for the one thing that can replace
it: CouldNotStart carries an exception's own message, whose length nothing here decides. Inside a fixed
height a long one would wrap and be clipped, at exactly the moment the text is the only thing this window
is for. So the height comes from the content, and MinHeight keeps the ordinary one line from looking
cramped in a box sized for it alone.
-->
<views:SplashCard x:Name="Card" />
</Window>
@@ -0,0 +1,56 @@
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>The window that is up while the application composes itself.</summary>
/// <remarks>
/// A frame around <see cref="SplashCard"/> and nothing else. Its whole lifetime is one call to
/// <c>DodoSshApp.ComposeBehind</c>: shown by the desktop lifetime as the main window, replaced by the real
/// one, closed. The exception is a composition that fails, which is the only case this window outlives.
/// </remarks>
internal sealed partial class SplashWindow : Window
{
/// <summary>
/// Whether Escape may close this.
/// </summary>
/// <remarks>
/// False while composition is running, and that is not caution for its own sake: this window has no
/// close button, so Escape is the only key it could offer — and closing it mid-composition would leave a
/// process that has opened the cache and bound a socket with no window and no way to reach either. Once
/// there is nothing left to wait for, it is the only way out and has to work.
/// </remarks>
private bool escapable;
public SplashWindow()
{
InitializeComponent();
KeyDown += (_, e) =>
{
if (escapable && e.Key == Key.Escape)
{
Close();
}
};
}
/// <summary>Says what is happening, while it still is.</summary>
internal void Say(string what) => Card.Say(what);
/// <summary>
/// Says why nothing is going to happen, and becomes the last window there is.
/// </summary>
/// <remarks>
/// The way out is named in the message rather than drawn as a button. A failure here is a broken
/// installation — a cache that cannot be opened, a port that cannot be bound — so what this window is
/// for at that point is carrying one sentence to whoever has to act on it, and a control that has to be
/// laid out and measured to say "close me" earns less than the words do.
/// </remarks>
internal void CouldNotStart(string why)
{
escapable = true;
Card.CouldNotStart($"DodoSSH could not start: {why} Press Escape to close.");
}
}
@@ -319,20 +319,25 @@
SelectedItem="{Binding SelectedLocalEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
<Grid Height="38" Margin="14,0,10,0">
<Grid Height="38" Margin="6,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="24" />
<ColumnDefinition Width="20" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
<!-- The remote pane's own tick, on this side and for the same reasons. -->
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}"
Click="OnLocalRowChecked" />
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
@@ -495,10 +500,50 @@
<Border Grid.Row="2" Padding="12,5" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1" IsVisible="{Binding ShowsRemoteItemActions}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" FontSize="11.5" VerticalAlignment="Center"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
Text="{Binding SelectedRemoteName}" />
<!--
The chosen row's name, or the count once anything is ticked. The count wins because it is the
one the buttons beside it are actually about: with three rows gathered up, a name would be
describing whichever of them happens to be highlighted while DELETE takes all three.
-->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="ghost" Content="ALL" Command="{Binding ToggleAllRemoteCommand}"
ToolTip.Tip="Tick every row, or untick them all" />
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
Text="{Binding SelectedRemoteName}"
IsVisible="{Binding !HasCheckedRemote}" />
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
Foreground="{StaticResource AccentText}"
Text="{Binding CheckedRemoteLabel}"
IsVisible="{Binding HasCheckedRemote}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
<!--
◆ FOUR NOW, and this is the one that was missing: the PERMS column has been a true fact
about a file with nothing to be done about it since it arrived, so the commonest next act
after using this screen was opening a terminal to chmod what had just been uploaded. Dark on
a bucket rather than live-and-refused — an object has an access policy rather than nine bits;
see CanChangeRemotePermissions.
A lock rather than a key glyph: a key is what this application's keychain uses, and one here
would read as "which credential" over a row that has nothing to do with one.
-->
<!--
◆ EDIT, and it is first because it is the only one of the five that changes nothing on the
host by pressing it: it fetches a copy and opens it. Dark on a head with no way to open a
file or watch one — see CanEditRemote, which is what the phone fails rather than this
button being absent there for a reason nobody can see.
-->
<Button Classes="paneicon" Command="{Binding EditRemoteCommand}"
IsEnabled="{Binding CanEditRemote}"
ToolTip.Tip="Open in this machine's editor, and send each save back">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE3C9;" />
</Button>
<Button Classes="paneicon" Command="{Binding ChangeRemotePermissionsCommand}"
IsEnabled="{Binding CanChangeRemotePermissions}"
ToolTip.Tip="Permissions on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE897;" />
</Button>
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE9AD;" />
@@ -515,6 +560,80 @@
</Grid>
</Border>
<!--
◆ WHAT AN EDIT SESSION SAYS FOR ITSELF, and it is a strip rather than a question: nothing is
waiting on an answer, a file is simply open somewhere else and each save is going back. It shares
the questions' Grid row because it is exclusive with them — opening any of the three needs a
selection, and while one is up ShowsRemoteItemActions has taken the buttons away.
The log is newest-first and bounded. Watching means saving happens more than once, and each one is
a round trip somebody may want to see happened; an editor that autosaves would otherwise grow this
without limit. See TransfersViewModel.RemoteEditLog.
The conflict card inside it is the one state where a save is not written back. Three answers,
because those are the three that exist: send it anyway, throw away what was typed and take the
host's copy, or stop. There is deliberately no merge — this client has no way to do one, and a
button that pretended otherwise would be the fabrication this project's honesty rule forbids.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsEditingRemote}">
<!--
◆ THE WHOLE STRIP SCROLLS, not just the log inside it, and that is what makes this safe at any
width. It is the tallest thing that can be in this Grid row — a heading, a path, a log, a
conflict card and a button — and unlike the three questions beside it, it is not modal: the
listing underneath stays live, because a file being open in an editor is no reason to stop
being able to browse. So it cannot be allowed to take the pane's height, and bounding the
container rather than each part means nothing added inside it later can undo that.
134 is measured. Inside the session shell this pane is 204x377, and ScreenLayoutTests draws
this in its worst case — the conflict card open with a session's worth of saves behind it.
-->
<ScrollViewer MaxHeight="134" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding RemoteEdit.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
Text="{Binding RemoteEdit.RemotePath}" />
<!-- Bounded inside a bounded strip: this keeps a long session's log from pushing the conflict
card and STOP EDITING out of view within the scroller. -->
<ScrollViewer MaxHeight="48" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding RemoteEditLog}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Classes="mono" FontSize="11" Margin="0,1"
Foreground="{StaticResource TextGhost}" TextTrimming="CharacterEllipsis"
Text="{Binding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Border Padding="10,8" CornerRadius="8" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1"
IsVisible="{Binding HasRemoteEditConflict}">
<StackPanel Spacing="6">
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding RemoteEditConflict}" />
<WrapPanel>
<Button Classes="ghost" Content="UPLOAD ANYWAY" Margin="0,0,8,4"
Command="{Binding ForceSaveEditCommand}" />
<Button Classes="ghost" Content="RELOAD" Margin="0,0,0,4"
Command="{Binding ReloadEditCommand}" />
</WrapPanel>
</StackPanel>
</Border>
<Button Classes="ghost" Content="STOP EDITING" HorizontalAlignment="Left"
Command="{Binding StopEditingCommand}" />
</StackPanel>
</ScrollViewer>
</Border>
<!--
The question DELETE asks. It names the full path rather than the file, because a name is the half
that does not identify anything — and that is now the whole of how the question says which row it
@@ -537,9 +656,28 @@
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource Danger}"
Text="{Binding PendingRemoteDeletion.FullPath}" />
<!--
Every path, one per line, and never a count on its own: agreeing to "3 items" is agreeing to a
number. See RemoteDeletionRequest.Listing.
◆ 32, AND THE NUMBER IS MEASURED RATHER THAN CHOSEN. A ticked set has no ceiling, so this list
has to scroll, and the budget it scrolls inside is small: within the session shell this pane is
204 wide, where one remote path wraps to two lines, and the card also carries a heading, the
consequence and two buttons that wrap onto two rows at that width. 120 put the buttons outside
the window; 72 left CANCEL three pixels under it; 56 and 40 pushed the listing's own squeezed
row past the bottom — each caught by ScreenLayoutTests, which is why the five-path test exists
beside the single-path one rather than instead of it. 32 is one wrapped path, which is exactly
what the single-path question has always shown.
What that buys is a scrollbar rather than a taller card, and the heading carries the count so
the number is never the thing being scrolled for.
-->
<ScrollViewer MaxHeight="32" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource Danger}"
Text="{Binding PendingRemoteDeletion.Listing}" />
</ScrollViewer>
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Consequence}" />
<WrapPanel>
@@ -603,6 +741,90 @@
</StackPanel>
</Border>
<!--
◆ THE MODE, as nine boxes and the number they add up to.
In the same Grid row as the other two questions and never at the same time as either — each one's
own opening clears the others, which is what makes one row enough for all three.
Boxes rather than a field to type 755 into, and that is the design rather than a preference: the
mode is already drawn as -rwxr-xr-x in the column this form was opened from, so a who-can-do-what
grid is the same representation the reader is already holding. An octal field would ask them to
translate something they can see into something they cannot check. The octal is printed underneath
as it changes, so the two readings check each other rather than one replacing the other.
The EXEC column is headed from the request rather than fixed, because on a directory that bit is
permission to enter it — the commonest misreading of a POSIX mode there is, and one word fixes it.
See RemotePermissionsRequest.ExecuteLabel.
APPLY is dead until something has actually changed. A round trip that sets a mode to the mode it
already has is not wrong, only pointless, and a live button over an untouched form invites it.
Not painted in the deletion card's red. Changing a mode is undone by changing it back; the red is
spent on the one thing here that nothing walks back, and spending it twice would make it stop
meaning anything — the same argument the rename form above makes for itself.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsChangingRemotePermissions}">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingRemotePermissions.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
Text="{Binding PendingRemotePermissions.FullPath}" />
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,Auto,Auto,Auto" Margin="0,2">
<TextBlock Grid.Row="0" Grid.Column="1" Classes="hint" FontSize="10.5" Text="read"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="2" Classes="hint" FontSize="10.5" Text="write"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="3" Classes="hint" FontSize="10.5"
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="11.5" Text="owner"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="11.5" Text="group"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="11.5" Text="others"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
</Grid>
<SelectableTextBlock Classes="mono" FontSize="12.5"
Foreground="{StaticResource AccentText}"
Text="{Binding PendingRemotePermissions.Preview}" />
<WrapPanel>
<Button Classes="accent" Content="APPLY" Margin="0,0,8,4"
Command="{Binding ConfirmRemotePermissionsCommand}"
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
Command="{Binding CancelRemotePermissionsCommand}" />
</WrapPanel>
</StackPanel>
</Border>
<!--
Away while a question is up, for the reason the item row above states: the trail is where the pane
is and MKDIR makes a directory, and neither is what somebody answering a question about one row
@@ -681,24 +903,42 @@
SelectedItem="{Binding SelectedRemoteEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<Grid Height="38" Margin="14,0,10,0">
<Grid Height="38" Margin="6,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="24" />
<ColumnDefinition Width="20" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
<ColumnDefinition Width="*" MaxWidth="66" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
<!--
◆ THE TICK IS NOT THE LIST'S SELECTION, and the two answer different questions. The
selection is "the row I am looking at", which is what rename, move and permissions act on
— each takes a form naming one path and is one-at-a-time by construction. This is "the
rows I have gathered up", which is what DOWNLOAD and DELETE act on, and it survives the
highlight moving.
It is drawn rather than left to ctrl-click for a reason beyond taste: the phone head shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a tap target
there whatever this pane does. One shape on both heads, and a state somebody can see.
Click is handled rather than bound one-way, because the count above the list is a property
of the collection rather than of any row — see TransfersViewModel.RemoteChecksChanged.
-->
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}"
Click="OnRemoteRowChecked" />
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Classes.exec="{Binding IsExecutable}"
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
<TextBlock Grid.Column="5" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</Grid>
@@ -732,8 +972,14 @@
the picker arrives, so the tall shape is the form rather than form-plus-prose, and neither shape
reaches the floor. Anything added here has to hold that; the layout suite measures both.
-->
<!--
ShowsRemoteInvitation rather than !IsConnected, which are no longer the same question. Pressing
DISCONNECT empties this pane, so an invitation over it covers nothing; a connection lost
underneath somebody leaves the listing exactly where it was, and covering that would hide the one
thing the strip below is offering to put them back into. See TransfersViewModel.ShowsRemoteContents.
-->
<Border Grid.Row="1" Grid.RowSpan="6" Background="{StaticResource Canvas}"
IsVisible="{Binding !IsConnected}">
IsVisible="{Binding ShowsRemoteInvitation}">
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
HorizontalAlignment="Center" VerticalAlignment="Center">
@@ -79,6 +79,20 @@ internal sealed partial class TransfersScreen : UserControl
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
/// <summary>
/// Tells the screen a row's tick moved, so the count and the buttons that read it keep up.
/// </summary>
/// <remarks>
/// The tick itself is two-way bound and needs nothing from here; what does is the count above the list,
/// which is a property of the collection rather than of any row. Subscribing per row would be a
/// subscription to attach and detach on every navigation, since each listing replaces every row it has.
/// See <c>TransfersViewModel.RemoteChecksChanged</c>.
/// </remarks>
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) => Transfers?.RemoteChecksChanged();
/// <inheritdoc cref="OnRemoteRowChecked" />
private void OnLocalRowChecked(object? sender, RoutedEventArgs e) => Transfers?.LocalChecksChanged();
private void OnLocalActivated(object? sender, TappedEventArgs e)
{
if (Transfers is { } transfers)
@@ -72,6 +72,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Microsoft.TSS": {
"type": "Direct",
"requested": "[2.1.1, )",
"resolved": "2.1.1",
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
@@ -1,12 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Reading an OpenSSH client configuration and turning it into hosts this application can store.
Turning somewhere an estate is already described into hosts this application can store. Two sources:
an OpenSSH client configuration on this machine, and a Hetzner Cloud project over its API.
Its own project rather than a folder in DodoSSH.Client.Domain, which holds decrypted item shapes and
their codecs and has no package references at all. A parser, a resolver and a file-system walk are a
different concern with different dependencies, and keeping them apart is what lets the whole of the
parsing be tested with no Avalonia, no SQLite and no disk.
The Hetzner reader is here rather than in a project of its own because it is the same concern arriving
from somewhere else: a candidate list, warnings about what could not be represented, and a HostSecret at
the end of it. It brings no package reference with it — HttpClient and System.Text.Json are the shared
framework — and it stays testable on the same terms, against a stubbed HttpMessageHandler rather than
against strings.
-->
<ItemGroup>
@@ -0,0 +1,476 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Import;
/// <summary>
/// Hetzner Cloud answered, and the answer was not a list of servers.
/// </summary>
/// <remarks>
/// One exception type with a sentence already fit to show somebody, rather than a status code every caller
/// would have to translate for itself. The status is kept beside it because "401" and "503" are different
/// problems with different next steps — one is the token, the other is nothing the user can do — and the
/// screen distinguishes them by <see cref="IsTokenProblem"/> rather than by matching on prose.
/// </remarks>
public sealed class HetznerCloudException : Exception
{
/// <summary>Creates a failure that has no underlying exception — a refusal Hetzner spelled out.</summary>
public HetznerCloudException(HttpStatusCode? statusCode, string? code, string message)
: this(statusCode, code, message, innerException: null)
{
}
/// <summary>Creates a failure wrapping the one the transport or the parser threw.</summary>
public HetznerCloudException(
HttpStatusCode? statusCode,
string? code,
string message,
Exception? innerException)
: base(message, innerException)
{
StatusCode = statusCode;
Code = code;
}
/// <summary>What Hetzner returned, or null where the request never got an answer at all.</summary>
public HttpStatusCode? StatusCode { get; }
/// <summary>Hetzner's own machine-readable error code, where the body carried one.</summary>
public string? Code { get; }
/// <summary>Whether the token is the thing to go and fix.</summary>
/// <remarks>
/// The wizard's first step is where a token gets typed, so a failure that is about the token belongs
/// back on that step and a failure that is about Hetzner's weather does not — sending somebody back to
/// re-type a token that was fine, because a datacentre had a bad minute, teaches them the token is
/// wrong.
/// </remarks>
public bool IsTokenProblem =>
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
}
/// <summary>
/// Reads the servers in one Hetzner Cloud project.
/// </summary>
/// <remarks>
/// <para>
/// <b>One token is one project.</b> Hetzner issues Cloud API tokens per project and the API offers no way
/// to ask which project a token belongs to, or to list the others — so this reads exactly the project whose
/// token it was handed, and the screen says as much rather than implying it found everything the account
/// owns. Somebody with four projects runs this four times.
/// </para>
/// <para>
/// <b>Read is all it does.</b> Only <c>GET /v1/servers</c> is ever called, which is why the screen asks for
/// a read-only token: a Read &amp; Write token would work identically here and would be a credential capable
/// of deleting the estate, pasted into a text box for no gain.
/// </para>
/// <para>
/// <b>The token is never stored.</b> It lives in the view model's field for as long as the wizard is open
/// and goes no further — not into the vault, not into settings, not into a log. The next import asks again,
/// which is the same reasoning <c>ImportViewModel.ImportsKeys</c> gives for not remembering its tick.
/// </para>
/// <para>
/// <b>Dedicated servers are not here.</b> Hetzner's Robot — the dedicated-server and Storage Box side of the
/// business — is a different API behind different credentials, and nothing in this file touches it. An
/// account with both gets its cloud servers from this wizard and adds the rest by hand.
/// </para>
/// </remarks>
public sealed class HetznerCloudClient
{
/// <summary>The Cloud API, and the one address in this file.</summary>
public static readonly Uri CloudApi = new("https://api.hetzner.cloud/v1/", UriKind.Absolute);
/// <summary>What Hetzner will return per page, which is also its maximum.</summary>
private const int PerPage = 50;
/// <summary>
/// How many pages this will follow before giving up.
/// </summary>
/// <remarks>
/// A backstop against a paginator that never says "no more pages" rather than a limit anybody's estate
/// will meet: at fifty a page this is five thousand servers, and the warning it emits on the way out
/// says the list is short rather than letting it look complete.
/// </remarks>
private const int MaxPages = 100;
private readonly HttpClient http;
/// <summary>Creates a reader over its own transport, which is how the application builds one.</summary>
public HetznerCloudClient()
: this(new HttpClient { BaseAddress = CloudApi })
{
}
/// <summary>Creates a reader with its transport supplied, which is how the tests reach it.</summary>
/// <remarks>
/// The base address is set here when the caller left it unset, so a stubbed <c>HttpMessageHandler</c>
/// can be handed over on its own without every test repeating the one URL this class knows.
/// </remarks>
public HetznerCloudClient(HttpClient http)
{
ArgumentNullException.ThrowIfNull(http);
http.BaseAddress ??= CloudApi;
this.http = http;
}
/// <summary>
/// Reads every server the token's project holds.
/// </summary>
/// <remarks>
/// Pages are followed rather than assumed away: twenty-six servers in a project is enough to meet the
/// second page, and an importer that quietly showed the first fifty would offer a list that looks
/// complete and is not.
/// </remarks>
/// <exception cref="HetznerCloudException">
/// The token was refused, the project could not be read, or Hetzner could not be reached. Every failure
/// arrives as this, with a sentence already fit to put on the screen.
/// </exception>
public async Task<HetznerImport> ListServersAsync(string token, CancellationToken cancellationToken)
{
var bearer = Sanitise(token);
var servers = new List<HetznerServer>();
var warnings = new List<string>();
var page = 1;
while (page <= MaxPages)
{
var read = await ReadPageAsync(bearer, page, cancellationToken).ConfigureAwait(false);
foreach (var server in read.Servers ?? [])
{
servers.Add(Convert(server, warnings));
}
if (read.Meta?.Pagination?.NextPage is not { } next || next <= page)
{
return new HetznerImport(servers, warnings);
}
page = next;
}
var counted = servers.Count.ToString(CultureInfo.InvariantCulture);
warnings.Add(
$"Stopped after {MaxPages.ToString(CultureInfo.InvariantCulture)} pages. This list is the first "
+ counted + " servers in the project and not all of them.");
return new HetznerImport(servers, warnings);
}
/// <summary>
/// Trims the pasted token and refuses one that could not be a header value.
/// </summary>
/// <remarks>
/// A token copied out of the Hetzner console arrives with a newline behind it often enough to be worth
/// handling, and a header value carrying one throws a <see cref="FormatException"/> from inside
/// <see cref="HttpClient"/> — a stack trace where the answer is "we trimmed it for you". Anything left
/// after the trim that still cannot be a header is refused here, with a sentence, rather than there.
/// </remarks>
private static string Sanitise(string token)
{
var trimmed = token?.Trim() ?? string.Empty;
if (trimmed.Length == 0)
{
throw new HetznerCloudException(null, null, "Paste a Hetzner Cloud API token first.");
}
foreach (var character in trimmed)
{
if (char.IsWhiteSpace(character) || char.IsControl(character))
{
throw new HetznerCloudException(
null,
null,
"That does not look like a Hetzner Cloud API token — it has a space or a line break in "
+ "the middle of it. A token is one unbroken run of 64 letters and digits.");
}
}
return trimmed;
}
/// <summary>Fetches and parses one page, turning every way it can fail into one exception type.</summary>
private async Task<HetznerServersPage> ReadPageAsync(
string token,
int page,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"servers?page={page}&per_page={PerPage}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
HttpResponseMessage response;
try
{
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException failure)
{
throw new HetznerCloudException(
null,
null,
$"Could not reach {CloudApi.Host}: {failure.Message}",
failure);
}
catch (TaskCanceledException failure) when (!cancellationToken.IsCancellationRequested)
{
throw new HetznerCloudException(
null,
null,
$"{CloudApi.Host} did not answer in time.",
failure);
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
throw await FailureAsync(response, cancellationToken).ConfigureAwait(false);
}
try
{
return await response.Content
.ReadFromJsonAsync(HetznerJsonContext.Default.HetznerServersPage, cancellationToken)
.ConfigureAwait(false)
?? new HetznerServersPage(null, null);
}
catch (JsonException failure)
{
throw new HetznerCloudException(
response.StatusCode,
null,
$"{CloudApi.Host} answered with something that is not a server list. That usually means "
+ "a proxy answered instead of Hetzner.",
failure);
}
}
}
/// <summary>
/// Turns a refusal into the sentence the screen shows.
/// </summary>
/// <remarks>
/// Hetzner's own message is kept and something actionable is put in front of it, because "unable to
/// authenticate" on its own does not tell somebody where tokens come from — and the token step is
/// exactly where they will be standing when they read it.
/// </remarks>
private static async Task<HetznerCloudException> FailureAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
string? code = null;
string? detail = null;
try
{
var body = await response.Content
.ReadFromJsonAsync(HetznerJsonContext.Default.HetznerErrorEnvelope, cancellationToken)
.ConfigureAwait(false);
code = body?.Error?.Code;
detail = body?.Error?.Message;
}
catch (JsonException)
{
// Not Hetzner's error shape. The status still says what happened, which is what the switch below
// is written against — a proxy's HTML 502 must not lose the 502 while being parsed.
}
catch (NotSupportedException)
{
// A content type that is not JSON at all, for the same reason.
}
var said = detail is { Length: > 0 } ? $" Hetzner said: {detail}" : string.Empty;
var status = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture);
var summary = response.StatusCode switch
{
HttpStatusCode.Unauthorized =>
"Hetzner did not accept that token. Cloud API tokens are made per project, in the Hetzner "
+ "console under Security → API tokens, and the value is shown only once — so a token "
+ "that was written down half-copied fails exactly like this.",
HttpStatusCode.Forbidden =>
"That token is not allowed to read this project's servers. A read-only token is enough; a "
+ "token scoped to something narrower is not.",
HttpStatusCode.TooManyRequests =>
"Hetzner is rate-limiting this token. Give it a minute and start the scan again.",
HttpStatusCode.NotFound =>
$"{CloudApi.Host} has no server list at that address, which should not happen and probably "
+ "means something between here and Hetzner answered instead.",
_ when (int)response.StatusCode >= 500 =>
$"Hetzner Cloud returned {status}. That is a fault at their end rather than anything about "
+ "the token.",
_ => $"Hetzner Cloud returned {status}.",
};
return new HetznerCloudException(response.StatusCode, code, summary + said);
}
/// <summary>
/// Turns one server as the API describes it into one the import can offer.
/// </summary>
/// <remarks>
/// Everything the API leaves out is tolerated rather than fatal, and only the name is worth a warning: a
/// server with no plan or no image loses a phrase from a note, and a server with no name would arrive as
/// a bookmark called nothing.
/// </remarks>
private static HetznerServer Convert(HetznerServerDto server, List<string> warnings)
{
var name = server.Name;
if (string.IsNullOrWhiteSpace(name))
{
name = string.Create(CultureInfo.InvariantCulture, $"server-{server.Id}");
warnings.Add($"A server had no name in the project, so it is offered as {name}.");
}
var privateAddresses = (server.PrivateNet ?? [])
.Select(network => network.Ip)
.Where(ip => !string.IsNullOrWhiteSpace(ip))
.Select(ip => ip!)
.ToList();
var labels = (server.Labels ?? new Dictionary<string, string>(StringComparer.Ordinal))
.Select(label => label.Value is { Length: > 0 }
? $"{label.Key}={label.Value}"
: label.Key)
.Order(StringComparer.Ordinal)
.ToList();
return new HetznerServer(
server.Id,
name,
server.Status is { Length: > 0 } status ? status : "unknown",
Blank(server.PublicNet?.IPv4?.Ip),
Blank(server.PublicNet?.IPv6?.Ip),
privateAddresses,
Blank(server.ServerType?.Name),
Where(server.Datacenter),
Blank(server.Image?.Name),
labels);
}
/// <summary>Where a server runs, as a person would say it — or null where the API said nothing useful.</summary>
private static string? Where(HetznerDatacenterDto? datacenter)
{
if (datacenter is null)
{
return null;
}
var location = datacenter.Location;
var region = Blank(location?.Name) ?? Blank(datacenter.Name);
var place = (Blank(location?.City), Blank(location?.Country)) switch
{
({ } city, { } country) => $"{city}, {country}",
({ } city, null) => city,
(null, { } country) => country,
_ => null,
};
return (region, place) switch
{
({ } where, { } which) => $"{where} · {which}",
({ } where, null) => where,
(null, { } which) => which,
_ => null,
};
}
/// <summary>Folds an absent value and a present-but-empty one together, which JSON does not.</summary>
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>One page of <c>GET /v1/servers</c>.</summary>
internal sealed record HetznerServersPage(
[property: JsonPropertyName("servers")] IReadOnlyList<HetznerServerDto>? Servers,
[property: JsonPropertyName("meta")] HetznerMetaDto? Meta);
/// <summary>The envelope every Hetzner list response carries its paging in.</summary>
internal sealed record HetznerMetaDto(
[property: JsonPropertyName("pagination")] HetznerPaginationDto? Pagination);
/// <summary>Only the forward link is read; the rest of Hetzner's paging block is not needed to walk it.</summary>
internal sealed record HetznerPaginationDto(
[property: JsonPropertyName("next_page")] int? NextPage);
/// <summary>One server, as the API describes it. Every field is optional because the API's are.</summary>
internal sealed record HetznerServerDto(
[property: JsonPropertyName("id")] long Id,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("status")] string? Status,
[property: JsonPropertyName("public_net")] HetznerPublicNetDto? PublicNet,
[property: JsonPropertyName("private_net")] IReadOnlyList<HetznerPrivateNetDto>? PrivateNet,
[property: JsonPropertyName("server_type")] HetznerNamedDto? ServerType,
[property: JsonPropertyName("datacenter")] HetznerDatacenterDto? Datacenter,
[property: JsonPropertyName("image")] HetznerNamedDto? Image,
[property: JsonPropertyName("labels")] IReadOnlyDictionary<string, string>? Labels);
/// <summary>A server's public addresses. Either half can be absent on a server that has only the other.</summary>
internal sealed record HetznerPublicNetDto(
[property: JsonPropertyName("ipv4")] HetznerIpDto? IPv4,
[property: JsonPropertyName("ipv6")] HetznerIpDto? IPv6);
/// <summary>
/// One public address.
/// </summary>
/// <remarks>
/// The v6 case puts a <em>network</em> in this field rather than an address — see
/// <see cref="HetznerServer.FirstIPv6Address"/>, which is where that is dealt with rather than papered over
/// here.
/// </remarks>
internal sealed record HetznerIpDto([property: JsonPropertyName("ip")] string? Ip);
/// <summary>A server's attachment to one private network.</summary>
internal sealed record HetznerPrivateNetDto([property: JsonPropertyName("ip")] string? Ip);
/// <summary>The shape shared by the server type and the image, of which only the name is read.</summary>
internal sealed record HetznerNamedDto([property: JsonPropertyName("name")] string? Name);
/// <summary>Where a server runs.</summary>
internal sealed record HetznerDatacenterDto(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("location")] HetznerLocationDto? Location);
/// <inheritdoc cref="HetznerDatacenterDto" />
internal sealed record HetznerLocationDto(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("city")] string? City,
[property: JsonPropertyName("country")] string? Country);
/// <summary>How Hetzner wraps a refusal.</summary>
internal sealed record HetznerErrorEnvelope(
[property: JsonPropertyName("error")] HetznerErrorDto? Error);
/// <inheritdoc cref="HetznerErrorEnvelope" />
internal sealed record HetznerErrorDto(
[property: JsonPropertyName("code")] string? Code,
[property: JsonPropertyName("message")] string? Message);
/// <summary>
/// The serializer for everything above.
/// </summary>
/// <remarks>
/// Source-generated rather than reflective, for the reason every other JSON context in this repository is:
/// the Android head publishes trimmed, and a reflective deserializer in an assembly the phone links is a
/// trim warning at best and a runtime failure at worst.
/// </remarks>
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(HetznerServersPage))]
[JsonSerializable(typeof(HetznerErrorEnvelope))]
internal sealed partial class HetznerJsonContext : JsonSerializerContext;
+237
View File
@@ -0,0 +1,237 @@
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Import;
/// <summary>
/// Which of a Hetzner server's addresses an import should dial.
/// </summary>
/// <remarks>
/// One choice for the whole import rather than one per row, for the reason the ssh_config screen's key tick
/// is one control: an estate is reached the same way throughout — everything over the public internet, or
/// everything across the private network from a machine already inside it — and a per-server dropdown would
/// be forty answers to one question.
/// </remarks>
public enum HetznerAddressKind
{
/// <summary>The public IPv4 address, which is how all but the cheapest Hetzner servers are reached.</summary>
PublicIPv4 = 0,
/// <summary>The public IPv6 address, for an estate that is v6 throughout — or a server with no v4 at all.</summary>
PublicIPv6 = 1,
/// <summary>The address on the first private network the server is attached to.</summary>
Private = 2,
}
/// <summary>
/// An address an import settled on, and what it had to assume to get there.
/// </summary>
/// <param name="Value">What the host will dial.</param>
/// <param name="Warning">
/// What is not simply a fact the API stated, or null where it is. Every one of these is a place the import
/// is deciding something on somebody's behalf, and a bookmark that dials the wrong machine is worse than one
/// that was never offered — so they are shown beside the row rather than folded away.
/// </param>
public sealed record HetznerAddress(string Value, string? Warning);
/// <summary>
/// One server a Hetzner Cloud project holds, as the import sees it.
/// </summary>
/// <remarks>
/// <para>
/// Deliberately not a <see cref="HostSecret"/>, on exactly the reasoning <see cref="ImportedHost"/> gives:
/// this is a candidate nobody has agreed to import, and it carries facts a stored host has no field for —
/// the server's own id, what it is running, where it runs, and which of its several addresses were on offer.
/// </para>
/// <para>
/// <b>There is no key here and there cannot be.</b> The Cloud API will name the SSH keys a project holds and
/// hand over their public halves; the private halves have never been in Hetzner's hands. So an imported host
/// authenticates with a key that was already in this keychain or it asks for a password, and the import
/// screen says so rather than leaving somebody to find out at connect time.
/// </para>
/// </remarks>
/// <param name="Id">The server's id in the project, which is the one name of it that never changes.</param>
/// <param name="Name">What the project calls it, which is what the user will recognise.</param>
/// <param name="Status">
/// <c>running</c>, <c>off</c>, <c>initializing</c> and the rest. Shown on the row and deliberately not
/// stored: it stays true for as long as it takes somebody to press a button in the Hetzner console.
/// </param>
/// <param name="PublicIPv4">The public IPv4 address, or null on a server that has none.</param>
/// <param name="PublicIPv6Network">
/// The public IPv6 <em>network</em> — Hetzner routes a whole /64 and the API reports the prefix, not an
/// address. See <see cref="FirstIPv6Address"/> for what this import does with that.
/// </param>
/// <param name="PrivateAddresses">The addresses it has on private networks, in the order the API gave them.</param>
/// <param name="ServerType">The plan — <c>cx22</c>, <c>ccx13</c> — or null where the API did not say.</param>
/// <param name="Location">Where it runs, as a person would say it: <c>fsn1 · Falkenstein, DE</c>.</param>
/// <param name="Image">What it was built from, which is the best available hint at which user logs in.</param>
/// <param name="Labels">The project's own labels, as <c>key=value</c>, sorted.</param>
public sealed record HetznerServer(
long Id,
string Name,
string Status,
string? PublicIPv4,
string? PublicIPv6Network,
IReadOnlyList<string> PrivateAddresses,
string? ServerType,
string? Location,
string? Image,
IReadOnlyList<string> Labels)
{
/// <summary>Whether the API gave this server a public IPv4 address at all.</summary>
/// <remarks>
/// Not a formality. Hetzner bills IPv4 separately now, and a project built since then can be entirely
/// IPv6-only — which is exactly the case an import that assumed a v4 would get silently wrong.
/// </remarks>
public bool HasPublicIPv4 => PublicIPv4 is { Length: > 0 };
/// <summary>
/// The address inside the routed /64 that a stock Hetzner image actually answers on.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is a convention and not a fact the API stated</b>, which is why every use of it carries a
/// warning. What the API reports is <c>2a01:4f8:1c17:d00::/64</c> — a network, which nothing can dial.
/// The address Hetzner's own images configure is the first one in it, and the console shows the same
/// thing; a server whose operator moved it elsewhere in the /64 will not answer here.
/// </para>
/// <para>
/// Computed by setting the low byte rather than by string surgery on the prefix, so a network the API
/// spells some other way still yields an address rather than a malformed string.
/// </para>
/// </remarks>
public string? FirstIPv6Address
{
get
{
if (PublicIPv6Network is not { Length: > 0 } network)
{
return null;
}
var slash = network.IndexOf('/', StringComparison.Ordinal);
var prefix = slash < 0 ? network : network[..slash];
if (!IPAddress.TryParse(prefix, out var parsed)
|| parsed.AddressFamily != AddressFamily.InterNetworkV6)
{
return null;
}
var bytes = parsed.GetAddressBytes();
bytes[^1] = 1;
return new IPAddress(bytes).ToString();
}
}
/// <summary>
/// Settles on the address this server would be dialled at, or null where the choice has none to offer.
/// </summary>
/// <remarks>
/// The two public choices fall back to each other and the private one does not, and that asymmetry is
/// deliberate. Falling back between v4 and v6 reaches the same machine over the same internet, so the
/// worst case is a route somebody did not pick; falling back from a private address to a public one
/// would put an address on the open internet into a bookmark that was asked to stay off it. So a server
/// with no private address is simply not importable under that choice, and the screen says which.
/// </remarks>
public HetznerAddress? Resolve(HetznerAddressKind kind)
{
var six = FirstIPv6Address;
return kind switch
{
HetznerAddressKind.PublicIPv4 when HasPublicIPv4 => new(PublicIPv4!, null),
HetznerAddressKind.PublicIPv4 when six is not null => new(
six,
$"No public IPv4. This is the first address of {PublicIPv6Network}, which is where Hetzner's "
+ "own images put the server — the API reports the network, not an address."),
HetznerAddressKind.PublicIPv6 when six is not null => new(
six,
$"The API reports {PublicIPv6Network} rather than an address. This is the first address in "
+ "it, which is where Hetzner's own images put the server."),
HetznerAddressKind.PublicIPv6 when HasPublicIPv4 => new(
PublicIPv4!,
"No public IPv6 on this server, so this is its IPv4 address."),
HetznerAddressKind.Private when PrivateAddresses.Count > 1 => new(
PrivateAddresses[0],
$"On {PrivateAddresses.Count} private networks. This is the first the API listed."),
HetznerAddressKind.Private when PrivateAddresses.Count == 1 => new(PrivateAddresses[0], null),
_ => null,
};
}
/// <summary>Turns this into the host that would be stored.</summary>
/// <remarks>
/// <para>
/// <b>Nothing here authenticates.</b> There is no key to bring — see the remark on this record — so the
/// host arrives bound to nothing and asks for a password until somebody points it at a key already in
/// the keychain. That is the same shape an <c>ssh_config</c> import has with its key tick off, and it is
/// stated on the screen rather than discovered at connect time.
/// </para>
/// <para>
/// <b>What goes in the notes is what does not go stale.</b> The id, the plan, the location and the image
/// survive; the power state does not, so it is a column on the import screen and never a note. A note
/// saying "running" about a machine somebody switched off last March is worse than no note.
/// </para>
/// </remarks>
/// <param name="address">The address to dial, from <see cref="Resolve"/>.</param>
/// <param name="username">The login user, or null and empty for none.</param>
/// <param name="port">The SSH port.</param>
public HostSecret ToSecret(HetznerAddress address, string? username, int port)
{
ArgumentNullException.ThrowIfNull(address);
return new HostSecret
{
Label = Name,
Hostname = address.Value,
Port = port,
Username = string.IsNullOrWhiteSpace(username) ? null : username.Trim(),
Notes = Describe(address),
};
}
/// <summary>What the note says about where this came from, plus whatever the address had to assume.</summary>
private string Describe(HetznerAddress address)
{
var facts = new List<string> { string.Create(CultureInfo.InvariantCulture, $"server {Id}") };
if (ServerType is { Length: > 0 } plan)
{
facts.Add(plan);
}
if (Location is { Length: > 0 } where)
{
facts.Add(where);
}
if (Image is { Length: > 0 } image)
{
facts.Add($"image {image}");
}
if (Labels.Count > 0)
{
facts.Add($"labels {string.Join(", ", Labels)}");
}
var sentence = $"Imported from Hetzner Cloud: {string.Join(", ", facts)}.";
return address.Warning is { Length: > 0 } warning ? $"{sentence} {warning}" : sentence;
}
}
/// <summary>
/// Everything a Hetzner Cloud project yielded: the servers it can offer, and what it could not.
/// </summary>
/// <param name="Servers">The importable candidates, in the order the API returned them.</param>
/// <param name="Warnings">
/// What the read could not represent, at project level — a page that came back malformed, a server the API
/// described without a name. The counterpart of <see cref="SshConfigImport.Warnings"/>.
/// </param>
public sealed record HetznerImport(IReadOnlyList<HetznerServer> Servers, IReadOnlyList<string> Warnings);
@@ -55,6 +55,24 @@ internal sealed class S3FileStore : IRemoteFileStore
/// </remarks>
public bool IsConnected => Volatile.Read(ref disposed) == 0;
/// <inheritdoc />
/// <remarks>
/// Null forever, for the reason <see cref="IsConnected"/> is always true: there is no connection under
/// this to be lost. A request that fails reports itself, and nothing here outlives one.
/// </remarks>
public string? TransportFailure => null;
/// <inheritdoc />
/// <remarks>
/// Never raised; see <see cref="TransportFailure"/>. Declared rather than left off the type because the
/// screen holds whichever store it opened as an <c>IRemoteFileStore</c> and subscribes without asking
/// which kind it got — an event that is simply never raised is what makes that honest rather than a
/// cast and a branch.
/// </remarks>
#pragma warning disable CS0067 // The event is never used
public event EventHandler? ConnectionLost;
#pragma warning restore CS0067
/// <inheritdoc />
public string HomeDirectory => ObjectKeys.Root;
@@ -383,6 +401,32 @@ internal sealed class S3FileStore : IRemoteFileStore
/// a rename's clothing, and the failure mode is a half-moved directory.
/// </para>
/// </remarks>
/// <inheritdoc />
/// <remarks>
/// Refused rather than approximated. An object is governed by an ACL and a bucket policy, which are not
/// nine bits and do not map onto them — writing something plausible here would put a mode in a listing
/// that no request would ever be decided by.
/// </remarks>
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken) =>
throw new SftpPathException(
path,
"An object in a bucket has an access policy rather than a POSIX mode, so there is nothing here to set.");
/// <inheritdoc />
/// <remarks>
/// Refused for the reason above: an object's timestamp is when the service stored it, and there is no
/// request that says otherwise. A copy that claimed the source's time would be a date the bucket does
/// not agree with.
/// </remarks>
public Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken) =>
throw new SftpPathException(
path,
"An object's timestamp is when the bucket stored it, and cannot be set to anything else.");
/// <inheritdoc />
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fromPath);
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff. Show more