Compare commits

...
Author SHA1 Message Date
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
139 changed files with 20341 additions and 901 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">
<!--
+109 -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
@@ -920,10 +1003,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": [
+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.
+24
View File
@@ -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.
+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."
+12 -2
View File
@@ -115,10 +115,20 @@ 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>
/// <b>Nothing reads this setting yet, and that is the point of saying so here.</b> The relay is
/// an ADR and a contract; there is no endpoint and no dialler. This option exists so the shape
/// is settled before the code is, which makes it exactly the kind of comment that gets read as a
/// description of an implemented control. It is not one. Whoever builds the dialler owes ADR 0004
/// §3 in full — loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses
/// denied with no configuration escape, and IPv4-mapped IPv6 normalised and re-checked — and this
/// flag governs RFC1918 only, never that list.
/// </para>
/// </remarks>
public bool AllowPrivateNetworks { get; set; } = true;
+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>
@@ -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
+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();
}
+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);
+20
View File
@@ -102,6 +102,26 @@ public sealed record ClientPaths(string DataDirectory)
/// </remarks>
public string DeviceKeyFile => Path.Combine(DataDirectory, "device.key");
/// <summary>
/// The file one desktop instance holds open for as long as it is running.
/// </summary>
/// <remarks>
/// <para>
/// Named here rather than in the head that uses it, because every other name in this directory is named
/// here and a filename invented somewhere else is one nobody looking at the profile can account for. The
/// Android head has no use for it: the platform allows one process per package and needs no help.
/// </para>
/// <para>
/// <b>Being in the profile directory is what scopes it correctly, and that is the whole reason it is a
/// file rather than a named mutex.</b> This path is already per user and per channel, so a nightly and a
/// release build do not exclude each other and two people signed into one machine each get their own
/// instance — neither of which a global name would give without repeating the same two decisions in
/// another form. Contents are irrelevant and it stays empty; what carries the meaning is the exclusive
/// handle, which the operating system releases when the process ends however it ends.
/// </para>
/// </remarks>
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
/// <summary>Creates the profile directory if it is not there yet.</summary>
/// <remarks>
/// Separate from resolving the path, because resolving must never have a side effect: it is read
@@ -0,0 +1,863 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Import;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>Which of the wizard's three steps is on screen.</summary>
/// <remarks>
/// A wizard rather than one screen, unlike <see cref="ImportViewModel"/>, and the reason is what each step
/// costs. Reading an <c>ssh_config</c> is free and local, so that screen scans the moment it opens and shows
/// a list. This one cannot: it needs a credential the user has to go and make in somebody else's console,
/// and it then makes a request over the internet. Those are two different moments, 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.
/// </remarks>
internal enum HetznerImportStep
{
/// <summary>Paste a token. Nothing has been sent yet.</summary>
Token = 0,
/// <summary>The project's servers, and what to do with them. Nothing has been stored yet.</summary>
Choose = 1,
/// <summary>What was stored.</summary>
Done = 2,
}
/// <summary>
/// One Hetzner server, as a row somebody decides about.
/// </summary>
/// <remarks>
/// The address is not a property of the server but of the server <em>and the choice above the list</em>, so
/// it is pushed down by <see cref="Refresh"/> rather than computed here — the same shape
/// <see cref="ImportRowViewModel.ImportsItsKey"/> has, and for the same reason: a control that decides one
/// thing for forty rows belongs above the forty rows.
/// </remarks>
internal sealed partial class HetznerRowViewModel(HetznerServer server) : ObservableObject
{
private HetznerAddress? resolved;
internal HetznerServer Server => server;
internal string Name => server.Name;
/// <summary>Whether it is running, off, or somewhere in between — a fact of the moment, never stored.</summary>
internal string Status => server.Status;
/// <summary>The plan and where it runs, as one cell.</summary>
internal string Kind => (server.ServerType, server.Location) switch
{
({ Length: > 0 } plan, { Length: > 0 } place) => $"{plan} · {place}",
({ Length: > 0 } plan, _) => plan,
(_, { Length: > 0 } place) => place,
_ => "—",
};
/// <summary>What this row would dial, or an em dash where the address choice leaves it nothing.</summary>
internal string Address => resolved?.Value ?? "—";
/// <summary>Whether the address choice above the list can reach this server at all.</summary>
/// <remarks>
/// False is the private-network case: a server on no private network has no private address, and this
/// import will not quietly fall back to a public one. Such a row cannot be ticked, which is what stops
/// an import that was asked to stay off the internet from putting a public address in the keychain.
/// </remarks>
internal bool CanImport => resolved is not null;
/// <summary>Whether the keychain already has a host pointing where this one would.</summary>
internal bool AlreadyPresent { get; private set; }
/// <summary>What had to be assumed to reach that address, if anything.</summary>
internal string Warning => resolved?.Warning ?? string.Empty;
internal bool HasWarning => resolved?.Warning is { Length: > 0 };
/// <summary>
/// The row's own chip: the single most actionable thing true about it.
/// </summary>
/// <remarks>
/// Ordered by what a person has to do something about. "No address" means this row cannot be imported
/// at all and wins outright; a warning is a fact about where it will connect and wins over "already
/// here", which is only about whether it is worth ticking. Deliberately parallel to
/// <see cref="ImportRowViewModel.Meaning"/> — the same three states plus the one that screen cannot
/// have, because a resolved <c>ssh_config</c> entry always has a hostname.
/// </remarks>
internal string Meaning => (CanImport, HasWarning, AlreadyPresent) switch
{
(false, _, _) => "no address of that kind",
(_, true, _) => Warning,
(_, _, true) => "already here",
_ => "new host",
};
internal bool IsMeaningWarned => !CanImport || HasWarning;
internal bool IsMeaningExisting => CanImport && !HasWarning && AlreadyPresent;
internal bool IsMeaningNew => CanImport && !HasWarning && !AlreadyPresent;
/// <summary>The address, resolved, or null where there is none of the chosen kind.</summary>
internal HetznerAddress? Resolved => resolved;
[ObservableProperty]
private bool isSelected;
/// <summary>
/// Re-resolves this row against the address choice above the list.
/// </summary>
/// <param name="kind">Which address the import is dialling.</param>
/// <param name="isAlreadyPresent">Whether the keychain already holds a host at a given address.</param>
/// <param name="resetSelection">
/// Whether to put the tick back to its default. True on a fresh scan and after an import — where the
/// question "which of these do you want" is being put again — and false when only the address choice
/// moved, because throwing away somebody's ticks because they changed a dropdown would be its own bug.
/// A row that has lost its address is unticked either way; there is nothing to import.
/// </param>
internal void Refresh(
HetznerAddressKind kind,
Func<HetznerAddress, bool> isAlreadyPresent,
bool resetSelection)
{
resolved = server.Resolve(kind);
AlreadyPresent = resolved is not null && isAlreadyPresent(resolved);
if (resetSelection)
{
// A server the keychain already points at starts unticked, exactly as the ssh_config import's
// rows do: importing it again is allowed — a second bookmark for one machine is a thing people
// want — but it should take a click rather than be the default.
IsSelected = CanImport && !AlreadyPresent;
}
else if (!CanImport)
{
IsSelected = false;
}
OnPropertyChanged(nameof(Address));
OnPropertyChanged(nameof(CanImport));
OnPropertyChanged(nameof(AlreadyPresent));
OnPropertyChanged(nameof(Warning));
OnPropertyChanged(nameof(HasWarning));
OnPropertyChanged(nameof(Meaning));
OnPropertyChanged(nameof(IsMeaningWarned));
OnPropertyChanged(nameof(IsMeaningExisting));
OnPropertyChanged(nameof(IsMeaningNew));
}
}
/// <summary>
/// One entry in the import's key picker: a key already in the keychain, or none at all.
/// </summary>
/// <param name="KeyId">The key, or null for "no key".</param>
/// <param name="VaultId">The vault that key lives in, or null for the "no key" entry.</param>
/// <param name="Label">What the picker shows.</param>
/// <remarks>
/// <para>
/// ◆ <b>An id rather than the key, because nothing here reads key material.</b> The import writes
/// <c>HostSecret.SshKeyId</c> and stops; the private half stays where it already was, in the one vault
/// entry every host that uses it points at. That is the same argument <c>VaultViewModel.ImportHostsAsync</c>
/// makes for storing one key per file rather than one per host, arrived at from the other end: here there
/// is no file at all, only a key the keychain has had all along.
/// </para>
/// <para>
/// A sentinel entry for "no key" rather than a null selection, for the reason <c>GroupChoice.None</c> gives:
/// a picker with nothing selected and a picker meaning "ask for a password" look identical and are not the
/// same thing — and on this screen the second is a promise about how forty machines will behave.
/// </para>
/// </remarks>
internal sealed record HetznerKeyChoice(Guid? KeyId, Guid? VaultId, string Label)
{
/// <summary>The "bind nothing" entry, always first and selected until somebody chooses otherwise.</summary>
/// <remarks>
/// The default, and deliberately: pointing an estate at a key is a claim about which key opens those
/// machines, and Hetzner cannot tell this screen that — the API names a project's keys but has never
/// held a private half. Guessed wrong it is forty hosts that fail to authenticate with a reason nobody
/// typed. Chosen, it is one answer to one question the person running the import already knows.
/// </remarks>
internal static HetznerKeyChoice None { get; } = new(null, null, "No key — ask for a password");
}
/// <summary>
/// Importing the servers in a Hetzner Cloud project.
/// </summary>
/// <remarks>
/// <para>
/// <b>Three steps, and the first two write nothing.</b> Step one takes a token and sends one request; step
/// two shows what the project holds and lets somebody choose; step three says what was stored. The split is
/// the same argument <see cref="ImportViewModel"/> makes — a project with forty servers is forty questions,
/// and only the person reading the list knows which of them they want bookmarked — with one addition this
/// import has and that one does not: the credential. It has to be fetched from another company's console
/// before anything here can happen at all, which is a step whether or not it is drawn as one.
/// </para>
/// <para>
/// <b>The token is never stored.</b> It lives in <see cref="Token"/> while the wizard is open and is cleared
/// the moment the read is done. Not in the vault, not in settings, not in a log — the next import asks
/// again. That is the same reasoning <see cref="ImportViewModel.ImportsKeys"/> gives for not remembering its
/// tick: a remembered answer is an answer to a question that was never put twice, and this one would be a
/// stored credential capable of listing somebody's whole estate.
/// </para>
/// <para>
/// <b>Nothing imported here authenticates.</b> Hetzner has never held the private half of an SSH key, so
/// every host arrives bound to nothing and asks for a password until somebody points it at a key already in
/// the keychain. The screen says so before the button rather than leaving it to be discovered at connect
/// time — see <see cref="AuthenticationNote"/>.
/// </para>
/// </remarks>
internal sealed partial class HetznerImportViewModel(
VaultViewModel vault,
HetznerCloudClient hetzner,
Action? onCancel = null) : ObservableObject
{
/// <summary>
/// The user a stock Hetzner Linux image logs in as.
/// </summary>
/// <remarks>
/// Every image Hetzner publishes puts the project's SSH keys on root and no other account exists yet, so
/// this is right far more often than it is wrong — and where it is wrong, it is wrong for a whole
/// estate at once, which is why the box is above the list rather than on each row.
/// </remarks>
internal const string DefaultUsername = "root";
internal ObservableCollection<HetznerRowViewModel> Rows { get; } = [];
/// <summary>What the project could not be made to say, at project level.</summary>
internal ObservableCollection<string> Warnings { get; } = [];
[ObservableProperty]
private HetznerImportStep step = HetznerImportStep.Token;
/// <summary>
/// The Cloud API token, held only for as long as the wizard needs it.
/// </summary>
/// <remarks>
/// A plain bound string, like the passphrase box on the unlock screen, and it carries the same caveat:
/// a .NET string cannot be zeroed, so what this really promises is that nothing here writes it anywhere
/// that outlives the process. <see cref="ScanAsync"/> clears it as soon as the read is finished.
/// </remarks>
[ObservableProperty]
private string token = string.Empty;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private bool isBusy;
/// <summary>The login user every imported host gets.</summary>
[ObservableProperty]
private string username = DefaultUsername;
/// <summary>
/// The SSH port, as typed.
/// </summary>
/// <remarks>
/// A string rather than an int because a text box passes through "" and "2" on the way to "22", and a
/// bound int would either refuse the keystroke or silently substitute a number nobody typed. Parsed by
/// <see cref="Port"/>, which is also what <see cref="IsPortValid"/> reports on.
/// </remarks>
[ObservableProperty]
private string portText = HetznerPort.ToString(CultureInfo.InvariantCulture);
private const int HetznerPort = 22;
[ObservableProperty]
private HetznerAddressKind addressKind = HetznerAddressKind.PublicIPv4;
internal bool IsTokenStep => Step is HetznerImportStep.Token;
internal bool IsChooseStep => Step is HetznerImportStep.Choose;
internal bool IsDoneStep => Step is HetznerImportStep.Done;
/// <summary>"Step 2 of 3", for the header — a wizard that does not say how long it is is a maze.</summary>
internal string StepLabel => $"STEP {(int)Step + 1} OF 3";
/// <summary>What this step is for, in the header, beside <see cref="StepLabel"/>.</summary>
internal string StepTitle => Step switch
{
HetznerImportStep.Token => "Connect to a project",
HetznerImportStep.Choose => "Choose what to import",
_ => "Imported",
};
/// <summary>
/// Which of the three address chips above the list is lit.
/// </summary>
/// <remarks>
/// Read-only bools beside a command that sets the enum, rather than two-way bound ones, because the
/// control is a chip that runs <see cref="UseAddressCommand"/> — see <c>HetznerImportScreen.axaml</c>
/// for why chips rather than radio buttons, which this application has no style for.
/// </remarks>
internal bool UsesPublicIPv4 => AddressKind is HetznerAddressKind.PublicIPv4;
/// <inheritdoc cref="UsesPublicIPv4" />
internal bool UsesPublicIPv6 => AddressKind is HetznerAddressKind.PublicIPv6;
/// <inheritdoc cref="UsesPublicIPv4" />
internal bool UsesPrivate => AddressKind is HetznerAddressKind.Private;
/// <summary>What the chosen address means for the list, said once above it rather than per row.</summary>
internal string AddressNote => AddressKind switch
{
HetznerAddressKind.PublicIPv4 =>
"The public IPv4 address, which is how all but the cheapest Hetzner servers are reached. A "
+ "server with no IPv4 falls back to its IPv6 and the row says so.",
HetznerAddressKind.PublicIPv6 =>
"The public IPv6 address. Hetzner routes a whole /64 and the API reports the network rather "
+ "than an address, so this is the first address in it — where Hetzner's own images put "
+ "the server, and not something the API states.",
_ =>
"The address on the server's own private network, which only reaches it from inside that "
+ "network. Servers on no private network are not importable under this choice, and this "
+ "will never quietly fall back to a public address.",
};
internal bool HasRows => Rows.Count > 0;
internal bool HasWarnings => Warnings.Count > 0;
/// <summary>The port every imported host gets, or null where what is typed is not one.</summary>
internal int? Port =>
int.TryParse(PortText, NumberStyles.None, CultureInfo.InvariantCulture, out var port)
&& port is >= 1 and <= 65535
? port
: null;
internal bool IsPortValid => Port is not null;
internal int SelectedCount => Rows.Count(row => row.IsSelected && row.CanImport);
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 SERVER" : $"IMPORT {SelectedCount} SERVERS";
/// <summary>How many rows the address choice can actually reach.</summary>
internal int ImportableCount => Rows.Count(row => row.CanImport);
/// <summary>Whether every importable row is ticked — what the header's tick-all box shows.</summary>
internal bool AllTicked => ImportableCount > 0 && SelectedCount == ImportableCount;
/// <summary>Whether IMPORT would do anything.</summary>
internal bool CanImport => SelectedCount > 0 && IsPortValid && !IsBusy;
/// <summary>
/// Where the imported hosts go — see <see cref="VaultViewModel.ImportHostsAsync"/>.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A picker rather than the active vault, which is what the ssh_config importer still uses.</b>
/// The two are not the same question. That screen reads <c>~/.ssh/config</c> — this machine's own
/// account, on this machine — and "wherever I am standing" is a fair answer. This one reads a whole
/// project out of a company's console, and a project is exactly the thing a team shares: forty servers
/// filed into somebody's personal vault by default is either forty items nobody else can see, or forty
/// items moved one at a time afterwards.
/// </para>
/// <para>
/// Its own selection rather than <see cref="VaultViewModel.TargetVaults"/>'s, on the reasoning
/// <c>VaultViewModel.EditorVaultChoices</c> gives for the host editor's: that one is a standing
/// preference about where new items go, and this is a field of the import in front of you. Filled from
/// the same list, so it offers what every other "file this into" control offers — vaults this session
/// can both read and write.
/// </para>
/// </remarks>
internal ObservableCollection<VaultChoiceViewModel> VaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedVault;
/// <summary>Whether there is more than one vault to choose between.</summary>
/// <remarks>
/// Hidden entirely at one rather than shown disabled, as the keychain screen's own picker is: a control
/// offering one option is a question with no answer.
/// </remarks>
internal bool HasVaultChoice => VaultChoices.Count > 1;
/// <summary>The vault the import writes to, or null for the active one.</summary>
internal Guid? TargetVaultId => SelectedVault?.VaultId;
/// <summary>What the summary line calls the vault the hosts are going into.</summary>
internal string VaultName => SelectedVault?.Name ?? vault.VaultName;
/// <summary>
/// The keys every imported host could be bound to: "no key", then everything in the keychain.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The one thing this import could never do, done by pointing rather than by bringing.</b> Hetzner
/// has never held a private half, so no key can arrive <em>with</em> these hosts — but a key that is
/// already in the keychain is almost always the key that opens them, because it is the one whose public
/// half was handed to Hetzner when the servers were created. Binding it here is one answer for the whole
/// estate; without it, it was forty visits to forty host editors.
/// </para>
/// <para>
/// Every readable vault's keys, not the target vault's. A binding resolves across vaults — that is what
/// makes one key on twenty hosts in three vaults the arrangement keys exist for — so narrowing this to
/// the destination would refuse a working answer. What it costs is a host whose colleagues cannot
/// resolve its key, and that is said in <see cref="AuthenticationNote"/> rather than prevented here.
/// </para>
/// </remarks>
internal ObservableCollection<HetznerKeyChoice> KeyChoices { get; } = [];
[ObservableProperty]
private HetznerKeyChoice? selectedKey = HetznerKeyChoice.None;
/// <summary>Whether the keychain has a key to offer at all.</summary>
/// <remarks>
/// The picker goes entirely on a keychain with no keys in it, rather than standing there offering only
/// "no key" — which is a question with one answer, and an invitation to go looking for a control that
/// would do something.
/// </remarks>
internal bool HasKeyChoice => KeyChoices.Count > 1;
/// <summary>The key every imported host is bound to, where one was chosen.</summary>
internal Guid? BoundKeyId => SelectedKey?.KeyId;
/// <summary>How many ticked, out of how many, and where they land.</summary>
internal string SelectionSummary
{
get
{
var noun = Rows.Count == 1 ? "server" : "servers";
return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
}
}
/// <summary>
/// The sentence that says how an imported host will authenticate.
/// </summary>
/// <remarks>
/// <para>
/// Said before the button rather than after, on exactly the reasoning <c>ImportScreen.axaml</c> gives
/// for its own version: whether the credential comes with the host is the difference between a bookmark
/// that connects and one that asks for a password, and somebody who is not told will conclude the
/// import was broken.
/// </para>
/// <para>
/// Two sentences now rather than one, because the answer is a choice. The first half of the original —
/// that Hetzner cannot hand over a private half — is true either way and stays in both, since it is the
/// fact that explains why the key had to be picked from a list instead of arriving with the servers.
/// </para>
/// </remarks>
internal string AuthenticationNote => SelectedKey?.KeyId is null
? "Hetzner has never held the private half of an SSH key, so nothing here can bring one. With no key "
+ "chosen above, a server imported this way arrives asking for a password until you bind it to "
+ "one — from the host's own editor, or in bulk from the Hosts screen."
: $"Hetzner has never held the private half of an SSH key, so nothing here brings one: every server "
+ $"imported is pointed at '{SelectedKey.Label}', which was already in your keychain. The key "
+ $"itself is not copied and not read.{StrandedKeyNote}";
/// <summary>
/// What is said when the chosen key sits in a different vault from the hosts.
/// </summary>
/// <remarks>
/// The same fact the move panel names when a host leaves its key behind, and it matters more here
/// because it is being done to a whole estate at once: a binding resolves across every vault
/// <em>this</em> account can read, so the import connects perfectly from this machine and not at all
/// for a colleague who holds only the destination's key. Stated rather than prevented — one key used by
/// hosts in three vaults is what keys are for.
/// </remarks>
private string StrandedKeyNote =>
SelectedKey?.VaultId is { } keyVault && TargetVaultId is { } target && keyVault != target
? $" That key lives in another vault than {VaultName}. It resolves for you, so these hosts will "
+ $"connect here — but anyone who can read {VaultName} and not the key's own vault will "
+ "see hosts they cannot authenticate."
: string.Empty;
/// <summary>What the token step says about the token it is asking for.</summary>
/// <remarks>
/// Three facts, each of which somebody would otherwise have to learn the hard way: a token is per
/// project rather than per account, read-only is enough, and nothing here keeps it. The first is the one
/// that surprises people — an account with four projects runs this four times, and a wizard that did not
/// say so would look like it had lost most of the estate.
/// </remarks>
internal static string TokenNote =>
"A Cloud API token belongs to one project, so this imports one project's servers — an account with "
+ "several runs this once per project. Make one in the Hetzner console under Security → API "
+ "tokens; Read is the only permission this needs, and the token is used for a single request "
+ "and then forgotten. Dedicated servers live behind Hetzner's Robot instead and are not "
+ "covered here.";
/// <summary>Reads the project. Writes nothing, and forgets the token on the way out.</summary>
[RelayCommand]
private async Task ScanAsync(CancellationToken cancellationToken)
{
ClearRows();
RefreshChoices();
IsBusy = true;
try
{
Fill(await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true));
if (Rows.Count == 0)
{
// Deliberately not an error, and deliberately not a step forward: an empty project is a
// real answer to a correct token, and a list screen with nothing on it says less than this.
Status = "That project has no servers in it.";
return;
}
Status = Rows.Count == 1
? "Found 1 server. Nothing is stored until you press the button below."
: $"Found {Rows.Count} servers. Nothing is stored until you press the button below.";
Step = HetznerImportStep.Choose;
}
catch (HetznerCloudException failure)
{
// Stays on the token step, which is where the box is. See HetznerCloudException.IsTokenProblem
// for why a fault at Hetzner's end is worded differently from a refused token even though both
// land here: one of them is something the person reading this can act on.
Status = failure.Message;
}
finally
{
// Cleared whatever happened, including on the way to a list somebody is about to import from:
// the read is over and the token has no further use. A failed attempt clears it too, so a
// mistyped token is retyped rather than corrected in place — the lesser of the two annoyances
// against a credential sitting in a text box for the rest of the session.
Token = string.Empty;
IsBusy = false;
RaiseListState();
}
}
/// <summary>Stores the ticked servers as hosts.</summary>
[RelayCommand]
private async Task ImportAsync(CancellationToken cancellationToken)
{
if (Port is not { } port)
{
Status = "That is not a port. Give a number between 1 and 65535.";
return;
}
var chosen = Rows.Where(row => row.IsSelected && row.CanImport).ToList();
if (chosen.Count == 0)
{
Status = "Nothing is ticked.";
return;
}
IsBusy = true;
try
{
// Key and KeyPath stay null and the binding goes on the host itself: those two are for key
// material arriving with an import, and there is none — see HetznerKeyChoice. A null BoundKeyId
// leaves the host bound to nothing, which is what it always was.
var requests = chosen
.Select(row => new ImportedHostRequest(
row.Server.ToSecret(row.Resolved!, Username, port) with { SshKeyId = BoundKeyId },
Key: null,
KeyPath: null))
.ToList();
var outcome = await vault.ImportHostsAsync(requests, TargetVaultId, cancellationToken)
.ConfigureAwait(true);
// Re-resolved rather than cleared, so a row that was just imported now says "already here" —
// which is what makes coming back to the list and importing the rest of the project harmless
// and visible rather than harmless and confusing.
Refresh(resetSelection: true);
Status = WhatWasStored(outcome.Hosts);
Step = HetznerImportStep.Done;
}
finally
{
IsBusy = false;
RaiseListState();
}
}
/// <summary>Back to the token step, with the list dropped.</summary>
/// <remarks>
/// The list goes because the token did. Nothing here can refresh what it is showing without asking for a
/// credential again, and a "back" that left forty rows on screen over a step that no longer has the
/// means to have produced them would be showing a list it cannot stand behind.
/// </remarks>
[RelayCommand]
private void Restart()
{
ClearRows();
Status = string.Empty;
Step = HetznerImportStep.Token;
}
/// <summary>
/// Back from the last step to the list, with what was just stored marked as already here.
/// </summary>
/// <remarks>
/// Unlike <see cref="Restart"/> this keeps the rows, and it can: they were read before the token was
/// cleared and nothing about them has to be fetched again. What it does not do is claim they are fresh —
/// a server created in the Hetzner console since the scan is not in this list and no screen here says
/// otherwise.
/// </remarks>
[RelayCommand]
private void Review()
{
if (Rows.Count > 0)
{
Step = HetznerImportStep.Choose;
}
}
/// <summary>Points the whole import at one of the three kinds of address.</summary>
[RelayCommand]
private void UseAddress(HetznerAddressKind kind) => AddressKind = kind;
/// <summary>Ticks or unticks every importable row at once.</summary>
[RelayCommand]
private void ToggleAll()
{
var importable = Rows.Where(row => row.CanImport).ToList();
var target = SelectedCount < importable.Count;
foreach (var row in importable)
{
row.IsSelected = target;
}
RaiseListState();
}
/// <summary>The footer's Cancel button: back to the Preferences page, nothing stored.</summary>
/// <remarks>
/// A delegate rather than a reference up to <c>MainWindowViewModel</c>, on the reasoning
/// <see cref="ImportViewModel"/>'s own cancel gives: this type has no business knowing settings mode
/// exists, and a null delegate — nothing wired, as in a test building this directly — makes the button a
/// no-op rather than a crash.
/// </remarks>
[RelayCommand]
private void Cancel() => onCancel?.Invoke();
/// <summary>What the import did, in the sentence the screen keeps after it.</summary>
/// <remarks>
/// This screen's own sentence rather than the vault's, which the push that follows an import overwrites
/// with a sync report — right on a screen somebody is about to leave, wrong on the one they are still
/// standing on. It names the vault and the key because those are the two answers that were chosen above
/// rather than assumed: which vault is what somebody will look for on the Hosts screen, and whether
/// there is a key is what they would otherwise find out at the next connection.
/// </remarks>
private string WhatWasStored(int hosts) => (hosts == 1, SelectedKey?.KeyId is not null) switch
{
(true, true) =>
$"Imported 1 server into {VaultName}. It is on the Hosts screen, bound to '{SelectedKey!.Label}'.",
(true, false) =>
$"Imported 1 server into {VaultName}. It is on the Hosts screen, asking for a password until "
+ "you bind it to a key.",
(false, true) =>
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, bound to "
+ $"'{SelectedKey!.Label}'.",
_ =>
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, asking for a "
+ "password until you bind them to a key.",
};
/// <summary>Fills the list from what the project said, and resolves every row against the choices.</summary>
/// <remarks>
/// Its own method rather than part of <see cref="ScanAsync"/>, which is a request, a failure path and a
/// credential to forget already. Nothing here is stored: these rows are candidates until somebody ticks
/// them and presses the button.
/// </remarks>
private void Fill(HetznerImport import)
{
foreach (var server in import.Servers)
{
var row = new HetznerRowViewModel(server);
// The footer counts ticks, so it has to hear about one. Subscribed here rather than bound from
// the item template, because a template binding cannot reach the list it is inside without a
// parent binding — and the count is on the parent by definition.
row.PropertyChanged += OnRowChanged;
Rows.Add(row);
}
foreach (var warning in import.Warnings)
{
Warnings.Add(warning);
}
Refresh(resetSelection: true);
}
/// <summary>
/// Refills the vault and key pickers from the keychain, keeping whatever was already chosen.
/// </summary>
/// <remarks>
/// <para>
/// Run at the start of every scan rather than once in a constructor, because the wizard outlives a
/// sync: a vault shared with this account, or a key a colleague pushed, between opening this screen and
/// pressing SCAN is a real answer that a list built at construction time would not be offering.
/// </para>
/// <para>
/// Selections are carried across <em>by id</em>, which is the same rule the host list's own reload
/// follows: the row objects are all new, so keeping the old ones would leave a picker showing a vault
/// the import would not actually write to. Anything that has since gone falls back to the default —
/// the active vault, and no key.
/// </para>
/// </remarks>
private void RefreshChoices()
{
var vaultId = SelectedVault?.VaultId ?? vault.TargetVaultId;
var keyId = SelectedKey?.KeyId;
VaultChoices.Clear();
foreach (var choice in vault.TargetVaults)
{
VaultChoices.Add(choice);
}
SelectedVault = VaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId)
?? VaultChoices.FirstOrDefault();
KeyChoices.Clear();
KeyChoices.Add(HetznerKeyChoice.None);
foreach (var key in vault.Keys)
{
// The vault name where there is more than one to be in, exactly as the key list itself prints
// it: two vaults may each hold a "deploy", and a picker that binds forty machines to one of them
// has to say which. HasVaultBadge is that same test, already answered.
KeyChoices.Add(new HetznerKeyChoice(
key.EntityId,
key.VaultId,
key.HasVaultBadge ? $"{key.Label} · {key.VaultName}" : key.Label));
}
SelectedKey = KeyChoices.FirstOrDefault(choice => choice.KeyId == keyId) ?? HetznerKeyChoice.None;
OnPropertyChanged(nameof(HasVaultChoice));
OnPropertyChanged(nameof(HasKeyChoice));
}
/// <summary>Drops the list, unsubscribing from what it held.</summary>
/// <remarks>
/// The detach matters: rows outlive the list only through this handler, and a scan run four times would
/// otherwise leave three sets of dead rows still calling into a live view model on every tick.
/// </remarks>
private void ClearRows()
{
foreach (var row in Rows)
{
row.PropertyChanged -= OnRowChanged;
}
Rows.Clear();
Warnings.Clear();
RaiseListState();
}
/// <remarks>
/// Only the tick moves the footer. Every other property on a row changes as part of a refresh this type
/// started, which already ends in <see cref="RaiseListState"/>.
/// </remarks>
private void OnRowChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
{
if (string.Equals(args.PropertyName, nameof(HetznerRowViewModel.IsSelected), StringComparison.Ordinal))
{
RaiseListState();
}
}
/// <summary>Re-resolves every row against the current address choice.</summary>
private void Refresh(bool resetSelection)
{
foreach (var row in Rows)
{
row.Refresh(AddressKind, IsAlreadyPresent, resetSelection);
}
RaiseListState();
}
/// <summary>
/// Whether the keychain already holds a host that would dial where this one does.
/// </summary>
/// <remarks>
/// Matched on address, port and user rather than on name, for the reason
/// <see cref="ImportViewModel.IsAlreadyPresent"/> gives at more length: a machine bookmarked under
/// another name is the same machine, and matching on the label would offer a duplicate of something
/// already stored. Compared against the <em>resolved</em> host so that a stored host inheriting its port
/// or user from a group still matches.
/// </remarks>
private bool IsAlreadyPresent(HetznerAddress address) => vault.Hosts.Any(existing =>
string.Equals(existing.Host.Hostname, address.Value, StringComparison.OrdinalIgnoreCase)
&& existing.Resolved.Port.Value == Port
&& string.Equals(
existing.Resolved.Username.Value,
string.IsNullOrWhiteSpace(Username) ? null : Username.Trim(),
StringComparison.OrdinalIgnoreCase));
partial void OnStepChanged(HetznerImportStep value)
{
OnPropertyChanged(nameof(IsTokenStep));
OnPropertyChanged(nameof(IsChooseStep));
OnPropertyChanged(nameof(IsDoneStep));
OnPropertyChanged(nameof(StepLabel));
OnPropertyChanged(nameof(StepTitle));
}
/// <remarks>
/// The vault is named in three places on this step — the summary line, the note under it and the
/// sentence about a key left in another vault — and none of them is a control the picker owns.
/// </remarks>
partial void OnSelectedVaultChanged(VaultChoiceViewModel? value)
{
OnPropertyChanged(nameof(VaultName));
OnPropertyChanged(nameof(SelectionSummary));
OnPropertyChanged(nameof(AuthenticationNote));
}
/// <inheritdoc cref="OnSelectedVaultChanged" />
partial void OnSelectedKeyChanged(HetznerKeyChoice? value) =>
OnPropertyChanged(nameof(AuthenticationNote));
partial void OnAddressKindChanged(HetznerAddressKind value)
{
OnPropertyChanged(nameof(UsesPublicIPv4));
OnPropertyChanged(nameof(UsesPublicIPv6));
OnPropertyChanged(nameof(UsesPrivate));
OnPropertyChanged(nameof(AddressNote));
Refresh(resetSelection: false);
}
/// <remarks>
/// The user and the port are half of what "already here" is decided on, so changing either has to
/// re-ask the question — otherwise a row would go on claiming the keychain has it while the thing it
/// would now store is different.
/// </remarks>
partial void OnUsernameChanged(string value) => Refresh(resetSelection: false);
/// <inheritdoc cref="OnUsernameChanged" />
partial void OnPortTextChanged(string value)
{
OnPropertyChanged(nameof(IsPortValid));
Refresh(resetSelection: false);
}
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(CanImport));
private void RaiseListState()
{
OnPropertyChanged(nameof(HasRows));
OnPropertyChanged(nameof(HasWarnings));
OnPropertyChanged(nameof(SelectedCount));
OnPropertyChanged(nameof(ImportableCount));
OnPropertyChanged(nameof(ImportLabel));
OnPropertyChanged(nameof(AllTicked));
OnPropertyChanged(nameof(CanImport));
OnPropertyChanged(nameof(SelectionSummary));
}
}
@@ -371,7 +371,7 @@ internal sealed partial class ImportViewModel(
var requests = chosen.Select(BuildRequest).ToList();
var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true);
var outcome = await vault.ImportHostsAsync(requests, vaultId: null, cancellationToken).ConfigureAwait(true);
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
// pressing the button twice harmless and visible rather than harmless and confusing.
@@ -182,6 +182,17 @@ internal enum ShellScreen
/// <see cref="TransfersViewModel.Remote"/>; see <see cref="OnScreenChanged"/>.
/// </remarks>
Buckets = 10,
/// <summary>Importing hosts from a Hetzner Cloud project.</summary>
/// <remarks>
/// Reached from preferences and not from the nav rail, exactly as <see cref="Import"/> is, and for the
/// same reason: it is a task done once rather than a place to be. A member of its own rather than a mode
/// of <see cref="Import"/>, because the two read different things through different code and draw
/// different screens — a single "import" destination that remembered which kind you last opened would be
/// a destination whose meaning depended on history, which is the argument <see cref="Buckets"/> already
/// makes for not sharing a member with <see cref="Transfers"/>.
/// </remarks>
HetznerImport = 11,
}
/// <summary>
@@ -328,6 +339,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly Func<string, Task>? copyToClipboard;
/// <remarks>
/// The other direction, and it exists for one caller: a right-click in a terminal with nothing selected.
/// The renderer cannot read a clipboard — <see cref="TerminalClientOpcode.ClipboardPaste"/> says why —
/// so the gesture crosses the socket and is answered here. Null on a head that wires none, which is now
/// no head at all: it stays nullable because this class has to be constructible without a visual, which
/// is what every test that drives it does.
/// </remarks>
private readonly Func<Task<string?>>? readClipboard;
/// <summary>
/// What explains a connection that would not reach its host, handed to every <see cref="VaultViewModel"/>
/// this shell builds.
/// </summary>
/// <remarks>
/// Held rather than passed straight through, because the vault is not built here: it is built on unlock,
/// which happens once per session and possibly more than once. Null means the standard probe, which is
/// what both heads use — a test passes one to keep a failed connection from putting a real DNS lookup and
/// a real socket in the middle of a unit suite.
/// </remarks>
private readonly ISshReachabilityProbe? reachability;
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
private readonly Action<Action> post;
@@ -370,6 +402,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
/// <summary>
/// The tab the next connection attempt belongs to, while a reconnect is being started.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A field rather than a parameter because the two ends of this are not the same call.</b> Dialling
/// goes through the vault's own connect command — which is the whole point, since that is where
/// credentials, host keys and every refusal live — and what comes back is a <c>ConnectionStarting</c>
/// event that has never heard of tabs. This is how the attempt that event announces is recognised as
/// belonging to a tab that already exists instead of getting a new one.
/// </para>
/// <para>
/// It cannot be stale by the time it is read. <c>ConnectionStarting</c> is raised before the vault's
/// first await, on this thread, so the set and the read are the same turn of the dispatcher — and
/// <see cref="ReconnectTabAsync"/> clears it afterwards for the paths that never got that far, which is
/// every refusal the vault makes before it dials.
/// </para>
/// </remarks>
private TerminalTabViewModel? reconnecting;
/// <summary>How often the hosts screen's ago-text is restrung while it is visible.</summary>
/// <remarks>
/// A minute, to match the words it is restringing into: "1 min ago" is the shortest gap the wording
@@ -473,6 +525,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
/// the Android head's distribution separately, so it must never acquire one by accident.
/// </param>
/// <param name="readClipboard">
/// Reads this machine's clipboard, or null on a head that cannot. Answers the terminal's right-click
/// paste and nothing else — see the field it is kept in.
/// </param>
/// <param name="openInEditor">
/// Opens a local path with whatever this machine opens that kind of file with, or null on a head that
/// has no such notion. Forwarded straight to <see cref="TransfersViewModel"/>, which is the only thing
/// that uses it — see its own remark for why this is a delegate and what null means.
/// </param>
/// <param name="watchFile">
/// Watches one local file for changes until the handle it returns is disposed, or null where nothing
/// can. Forwarded with <paramref name="openInEditor"/> and useless without it.
/// </param>
/// <param name="post">
/// Runs an action on the thread this shell's view models are read from. Defaults to the UI thread's
/// dispatcher, which is the answer in every real head.
@@ -498,10 +563,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null,
Func<string, Task>? copyToClipboard = null,
Func<Task<string?>>? readClipboard = null,
string? deviceName = null,
IUpdateChannel? updates = null,
Action<Action>? post = null)
Action<Action>? post = null,
Func<string, Task>? openInEditor = null,
Func<string, Action, IDisposable>? watchFile = null,
ISshReachabilityProbe? reachability = null)
{
this.reachability = reachability;
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
this.paths = paths;
@@ -514,6 +584,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.clock = clock;
this.passphraseProfile = passphraseProfile;
this.copyToClipboard = copyToClipboard;
this.readClipboard = readClipboard;
// Whitespace is treated as absent rather than honoured: an empty device name reaches the server as a
// blank one, which RegisterDevice rejects, and a phone whose model string came back empty would fail
@@ -523,7 +594,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// The fourth argument is the route out of the S3 screen's empty state. A bucket is made on the
// keychain screen, which is a different tab and two clicks away from somebody who has gone to S3
// to add one — so the screen that needs a bucket is given a way to reach the screen that makes one.
transfers = new TransfersViewModel(sftpSessions, clock, addBucket: ShowNewBucket);
transfers = CreateFileBrowser(sftpSessions, clock, openInEditor, watchFile);
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
@@ -544,6 +615,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// list. Detached in DisposeAsync, which is the only point either of them ends.
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
this.workspace.ClipboardCopyRequested += OnClipboardCopyRequested;
this.workspace.ClipboardPasteRequested += OnClipboardPasteRequested;
this.workspace.RendererReattached += OnRendererReattached;
settings = new ClientSettingsStore(paths);
@@ -592,6 +665,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
_ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
}
/// <summary>Builds the file browser, which is composed once and outlives every lock.</summary>
/// <remarks>
/// A method rather than four more lines in the constructor, which is already at the length the analyzers
/// allow — the same reason <see cref="CreateUpdateScreen"/> is one.
/// <para>
/// It is handed this shell's own posting delegate rather than left to default to the UI dispatcher,
/// which is what it did while nothing it posted was worth asserting on. A connection lost underneath the
/// file browser is raised on SSH.NET's message-listener thread and has to cross onto the thread these
/// view models are read from — the same crossing connection phases already make — and a suite with no
/// window has no way to drain a dispatcher it does not own. Both defaults are the UI dispatcher, so it
/// changes nothing in either head.
/// </para>
/// </remarks>
private TransfersViewModel CreateFileBrowser(
ISftpSessionFactory sftpSessions,
TimeProvider clock,
Func<string, Task>? openInEditor,
Func<string, Action, IDisposable>? watchFile) =>
new(
sftpSessions,
clock,
post: post,
addBucket: ShowNewBucket,
openInEditor: openInEditor,
watchFile: watchFile);
/// <summary>
/// Builds the updater, kept for the life of the process like the workspace and the transfer queue.
/// </summary>
@@ -669,6 +768,106 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
/// <summary>
/// A right-click over a selection: the page has sent the text, and this puts it on the clipboard.
/// </summary>
/// <remarks>
/// <para>
/// Marshalled for the reason <see cref="OnFontSizeStepRequested"/> is — this arrives on the data plane's
/// receive loop — plus one of its own, which is the stronger: an Avalonia clipboard is reached through a
/// <c>TopLevel</c>, and a <c>TopLevel</c> is only safe to touch from the thread that owns the window.
/// </para>
/// <para>
/// Through <see cref="post"/> rather than <c>Dispatcher.UIThread</c> directly, unlike the handlers
/// beside it: those predate the delegate and are the ones this shell's suite records as out of its
/// reach. There is no reason to add another.
/// </para>
/// </remarks>
private void OnClipboardCopyRequested(object? sender, TerminalClipboardCopyEventArgs e) =>
post(() => _ = CopyFromTerminalAsync(e.Text));
/// <summary>
/// A right-click with nothing selected: reads the clipboard and hands it to the pane that asked.
/// </summary>
/// <remarks>Marshalled for the reasons <see cref="OnClipboardCopyRequested"/> gives.</remarks>
private void OnClipboardPasteRequested(object? sender, TerminalClipboardPasteEventArgs e) =>
post(() => _ = PasteIntoTerminalAsync(e.SessionId));
/// <remarks>
/// <para>
/// Silence where there is no clipboard, and that is a different answer from the keychain's COPY buttons
/// — which say so, because a button that appears to do nothing is worse than a refusal. There is nowhere
/// to say it from here: the shell's status line is drawn on the enroll, sign-in and unlock cards, none
/// of which is on screen while somebody is right-clicking a terminal. Inventing a channel for a branch
/// no shipped head reaches would be the wrong trade, and both heads wire a clipboard.
/// </para>
/// <para>
/// A throw is swallowed rather than left to become an unobserved task exception. A clipboard is a shared
/// machine resource — on Windows another process holding it open is an ordinary, transient failure — and
/// a copy that did not happen is worth exactly one more right-click.
/// </para>
/// </remarks>
private async Task CopyFromTerminalAsync(string text)
{
if (copyToClipboard is null)
{
return;
}
try
{
await copyToClipboard(text).ConfigureAwait(true);
}
catch (Exception)
{
// Deliberately silent; see the remark for both halves of why. Nothing is left half done — the
// clipboard either took the text or did not.
}
}
/// <remarks>
/// <para>
/// An empty clipboard ends here rather than at the renderer. Sending it would bracket nothing and hand
/// the remote an empty paste, which is a keystroke nobody made; "there was nothing to paste" is a fact
/// about the machine rather than about this terminal, and the honest answer to it is that the
/// right-click did nothing — which is what every other terminal does with an empty clipboard too.
/// </para>
/// <para>
/// The answer for a session whose shell has ended is discarded for the reason
/// <see cref="TerminalWorkspace.SendClipboardAsync"/> gives for refusing it: nothing visible would
/// happen either way, and there is nowhere on this screen to say so.
/// </para>
/// <para>
/// A throw is swallowed, as in <see cref="CopyFromTerminalAsync"/> and for the same reason.
/// </para>
/// </remarks>
private async Task PasteIntoTerminalAsync(uint sessionId)
{
if (readClipboard is null)
{
return;
}
try
{
var text = await readClipboard().ConfigureAwait(true);
if (string.IsNullOrEmpty(text))
{
return;
}
await workspace
.SendClipboardAsync(sessionId, text, CancellationToken.None)
.ConfigureAwait(true);
}
catch (Exception)
{
// Deliberately silent; see the remark. The read and the send are both inside it, because
// whichever of the two failed leaves the same nothing on screen.
}
}
/// <remarks>
/// <para>
/// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
@@ -856,6 +1055,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private ImportViewModel? importScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private HetznerImportViewModel? hetznerImportScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private SnippetsViewModel? snippetsScreen;
@@ -1357,6 +1560,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return;
}
if (target is ShellScreen.HetznerImport)
{
OpenHetznerImport();
return;
}
// Any other screen leaves settings mode outright rather than restoring whatever was remembered on
// the way in — the caller named a destination, and that destination wins over "go back".
ActiveSettingsPage = null;
@@ -1419,10 +1628,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private bool isImportOpen;
/// <summary>
/// Whether the Preferences page itself, rather than the importer drawn over it, is what settings mode's
/// Whether the Hetzner wizard, rather than the ssh_config importer, is the one drawn over Preferences.
/// </summary>
/// <remarks>
/// A second flag rather than a second value of one, because they are two independent facts about the
/// content column and a single "which importer" enum would have to carry a "neither" member that
/// <see cref="IsImportOpen"/> already expresses. What keeps them from both being true is that every
/// path that opens one closes the other — see <see cref="OpenImport"/> and
/// <see cref="OpenHetznerImport"/>.
/// </remarks>
[ObservableProperty]
private bool isHetznerImportOpen;
/// <summary>
/// Whether either importer is up, which is the one thing the titlebar's back button cares about.
/// </summary>
/// <remarks>
/// Both back out to the same place by the same command, so the bar asks this rather than asking twice.
/// See <c>SettingsTitleBar.axaml</c>.
/// </remarks>
internal bool IsImporterOpen => IsImportOpen || IsHetznerImportOpen;
/// <summary>
/// Whether the Preferences page itself, rather than an importer drawn over it, is what settings mode's
/// content column shows.
/// </summary>
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImportOpen;
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImporterOpen;
partial void OnActiveSettingsPageChanged(SettingsPage? value)
{
@@ -1437,8 +1668,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
}
partial void OnIsImportOpenChanged(bool value) =>
partial void OnIsImportOpenChanged(bool value) => RaiseImporterState();
/// <inheritdoc cref="OnIsImportOpenChanged" />
partial void OnIsHetznerImportOpenChanged(bool value) => RaiseImporterState();
private void RaiseImporterState()
{
OnPropertyChanged(nameof(IsImporterOpen));
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
}
/// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary>
/// <remarks>
@@ -1461,6 +1700,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ActiveSettingsPage = page;
IsImportOpen = false;
IsHetznerImportOpen = false;
Screen = page switch
{
@@ -1482,9 +1722,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
IsImportOpen = true;
}
/// <summary>"Back to preferences": closes the importer without leaving settings mode.</summary>
/// <summary>
/// Opens the Hetzner wizard over the Preferences page — the Preferences row's own button, and
/// <see cref="ShowScreen"/>'s translation of <see cref="ShellScreen.HetznerImport"/>.
/// </summary>
/// <remarks>
/// Reopened from the first step each time rather than resumed where it was left, and that is a decision
/// rather than an omission: the wizard's first step holds a credential it deliberately does not keep, so
/// a "resumed" second step would be a list nothing on screen could refresh. See
/// <see cref="HetznerImportViewModel"/>.
/// </remarks>
private void OpenHetznerImport()
{
EnterSettings(SettingsPage.Preferences);
HetznerImportScreen?.RestartCommand.Execute(null);
IsHetznerImportOpen = true;
}
/// <summary>"Back to preferences": closes whichever importer is up, without leaving settings mode.</summary>
[RelayCommand]
private void CloseImport() => IsImportOpen = false;
private void CloseImport()
{
IsImportOpen = false;
IsHetznerImportOpen = false;
}
/// <summary>"Back to application": leaves settings mode for wherever it was entered from.</summary>
[RelayCommand]
@@ -1497,6 +1760,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ActiveSettingsPage = null;
IsImportOpen = false;
IsHetznerImportOpen = false;
Screen = settingsReturnScreen;
Surface = settingsReturnSurface;
}
@@ -2621,7 +2885,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ReconnectAsync,
copyToClipboard,
connectionLog,
visibility);
visibility,
reachability);
State = ShellState.Unlocked;
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
@@ -3146,6 +3411,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
workspace.ClipboardCopyRequested -= OnClipboardCopyRequested;
workspace.ClipboardPasteRequested -= OnClipboardPasteRequested;
workspace.RendererReattached -= OnRendererReattached;
transfers.PropertyChanged -= OnTransfersPropertyChanged;
@@ -3377,6 +3644,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
? null
: new ImportViewModel(newValue, new SshConfigLocator(), CloseImport);
// Its own transport, built once with the vault rather than per scan: an HttpClient is meant to be
// long-lived, and this one goes exactly one place — see HetznerCloudClient.CloudApi.
HetznerImportScreen = newValue is null
? null
: new HetznerImportViewModel(newValue, new HetznerCloudClient(), CloseImport);
SnippetsScreen?.Detach();
SnippetsScreen = newValue is null
? null
@@ -3471,7 +3744,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
{
var tab = new TerminalTabViewModel(e.Label, e.Address);
if (reconnecting is { } existing)
{
// A reconnect. The tab is already in the strip, already selected, and the user is looking at it —
// so it goes back to connecting in place rather than a second tab for the same machine appearing
// beside the one they pressed the button on. See ReconnectTabAsync.
reconnecting = null;
existing.Reconnecting();
attempts[e.AttemptId] = existing;
// The card replaces the pane the moment the session id is dropped, and both flags turn on that.
RaiseTerminalState();
RaiseSessionState();
return;
}
var tab = new TerminalTabViewModel(e.Label, e.Address) { HostId = e.HostId };
attempts[e.AttemptId] = tab;
AdoptTab(tab);
@@ -3535,6 +3825,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// dropping it would leave a shell running with nothing in the window naming it.
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
{
HostId = e.HostId,
StartedAt = clock.GetUtcNow(),
Cipher = NullIfEmpty(e.Cipher),
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
@@ -3745,6 +4036,321 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Surface = ShellSurface.Terminal;
}
/// <summary>
/// The host this tab was opened against, or null when the keychain no longer holds it.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Matched on the keychain item's id, never on the name.</b> Nothing stops two hosts being called
/// the same thing — a team's vault beside a personal one makes it ordinary, and "db" is what people call
/// their database server in both — and this is the lookup <see cref="ReconnectTabAsync"/> dials the
/// answer to. A name match would take a dropped session on one machine and quietly reopen a shell on
/// another, with the tab, the status bar and the address all still naming the first. The id is carried
/// from the attempt that opened the tab; see <see cref="TerminalTabViewModel.HostId"/>.
/// </para>
/// <para>
/// <b>It outlives a lock, which is what the name match was here to survive.</b> The id 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 its list around the same ids and a tab from before the lock finds its host in it. A host
/// renamed since the tab opened is found too.
/// </para>
/// <para>
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
/// able to say no: a machine reached by typing an address rather than from the keychain, which has no row
/// to dial again, and a tab whose host was deleted or whose vault is locked.
/// </para>
/// </remarks>
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
tab?.HostId is not { } hostId || Vault is not { } vault
? null
: vault.Hosts.FirstOrDefault(row => row.EntityId == hostId);
/// <summary>
/// Whether the selected tab has stopped and there is something to dial again.
/// </summary>
/// <remarks>
/// Both halves are needed and neither implies the other. The tab has to be finished — offering a
/// reconnect beside a live shell is offering to throw it away — and the machine has to still be in the
/// keychain, because that is the only place the credentials to redial it exist. A tab typed in by hand
/// fails the second half, which is honest: this client cannot dial it again without the password, and a
/// button that raised a password box under the word RECONNECT would be a different promise.
/// </remarks>
internal bool CanReconnectSelectedTab =>
IsTerminalSurface
&& SelectedTab is { CanOfferReconnect: true }
&& HostFor(SelectedTab) is not null;
/// <summary>
/// The word the status bar shows in place of CONNECTED once the selected tab's shell has stopped, or
/// null while it has not.
/// </summary>
/// <remarks>
/// <para>
/// Both surfaces, and only one word on the file side. A shell has two endings worth telling apart —
/// somebody typed <c>exit</c>, or the machine stopped answering — and a file session has one: this
/// screen closing it is a disconnect nobody needs told about, so the only ending left to word is the one
/// that happened on its own.
/// </para>
/// <para>
/// A bucket never reaches this. It is HTTP per request with no connection to lose, so
/// <see cref="TransfersViewModel.IsConnectionLost"/> is false for one however badly a request goes —
/// which is honest rather than a gap: a failed request against a bucket is a failed request, and the
/// screen already reports it as one.
/// </para>
/// </remarks>
internal string? SessionEndedText => Surface switch
{
ShellSurface.Terminal => SelectedTab?.EndedSummary,
_ when IsTransfersShowing => Transfers.IsConnectionLost ? "DISCONNECTED" : null,
_ => null,
};
/// <summary>Whether the selected tab's session ended because the connection broke.</summary>
/// <remarks>
/// Drawn in the palette's red where a shell that merely exited is drawn in grey, which is the whole
/// reason this is a second question rather than "did it end". One of the two is somebody having finished
/// what they were doing and the other is a machine that stopped answering.
/// </remarks>
internal bool IsSessionDropped => Surface switch
{
ShellSurface.Terminal => SelectedTab?.EndReason is SshSessionEnd.ConnectionLost,
// The file side's one ending is the lost one, so where the terminal has to ask which it was, this
// only has to ask whether there was one.
_ when IsTransfersShowing => Transfers.IsConnectionLost,
_ => false,
};
/// <summary>
/// Whether the status bar should say NOT CONNECTED rather than name an ending.
/// </summary>
/// <remarks>
/// The third of three, and it exists so that no two of them are ever drawn at once. A tab still dialling
/// is this; a tab whose shell has stopped has a word of its own, and printing NOT CONNECTED beside
/// DISCONNECTED would answer the same question twice in two vocabularies.
/// </remarks>
internal bool IsSessionIdle => !IsSessionConnected && SessionEndedText is null;
/// <summary>
/// The sentence under that word — what the remote or the transport actually said, or why a reconnect was
/// just refused.
/// </summary>
/// <remarks>
/// Usually the same string the renderer has already written into the pane. Drawn outside it as well
/// because the pane scrolls: a shell that printed a page of shutdown messages before it went has pushed
/// the line explaining itself off the screen, and this is the copy that stays put. See
/// <see cref="TerminalTabViewModel.EndedDetail"/> for the case where it is not that string.
/// </remarks>
internal string? SessionEndedDetail => Surface switch
{
ShellSurface.Terminal => SelectedTab?.EndedDetail,
_ when IsTransfersShowing => Transfers.LostConnection,
_ => null,
};
/// <summary>
/// Whether the strip under a stopped session may offer to dial again.
/// </summary>
/// <remarks>
/// One question resolved by surface, for the reason <see cref="SessionAddress"/> is: the strip is the
/// same markup on both, and a view branching on which one it is drawn over would be the shell's own job
/// done in XAML. The two halves are genuinely different questions — a terminal needs a keychain entry to
/// redial, and a file session already captured what it would dial when it connected.
/// </remarks>
internal bool CanReconnectSession => Surface switch
{
ShellSurface.Terminal => CanReconnectSelectedTab,
_ when IsTransfersShowing => Transfers.IsConnectionLost,
_ => false,
};
/// <summary>What the strip's second button says, which is not the same act on the two surfaces.</summary>
/// <remarks>
/// A terminal's tab is a thing that can be taken away and a file session's pane is not — there is one of
/// it, and what "dismiss" means there is putting the screen back to its invitation. Two labels rather
/// than one vague one, because a button that said CLOSE on both would be doing noticeably different
/// things.
/// </remarks>
internal string SessionEndedDismissLabel => IsTerminalSurface ? "CLOSE TAB" : "DISCONNECT";
/// <summary>Dials again, whichever kind of session stopped.</summary>
/// <remarks>
/// Each half is the surface's own reconnect rather than a third path: the terminal's goes through the
/// vault's connect command into the tab that stopped, and the file side's re-opens on the remote it
/// captured and walks back to the directory it was showing.
/// </remarks>
[RelayCommand]
private async Task ReconnectSessionAsync(CancellationToken cancellationToken)
{
if (IsTerminalSurface)
{
await ReconnectTabAsync(SelectedTab).ConfigureAwait(true);
return;
}
if (IsTransfersShowing)
{
await Transfers.ReconnectCommand.ExecuteAsync(null).ConfigureAwait(true);
}
}
/// <summary>Takes the stopped session away, whichever kind it is.</summary>
/// <inheritdoc cref="SessionEndedDismissLabel" path="/remarks" />
[RelayCommand]
private async Task DismissEndedSessionAsync()
{
if (IsTerminalSurface)
{
if (SelectedTab is { } tab)
{
await CloseTabAsync(tab).ConfigureAwait(true);
}
return;
}
if (IsTransfersShowing)
{
await Transfers.DisconnectCommand.ExecuteAsync(null).ConfigureAwait(true);
}
}
/// <summary>
/// Opens a new shell on the machine a stopped tab was pointed at, in that tab.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>It dials through the vault's ordinary connect path, and that is the whole design.</b> Every
/// refusal a connection can meet — a key that will not resolve, an unknown host key, a mismatch, a group
/// default that has changed since the first attempt — is decided in one place, and a reconnect that
/// reached past it into <c>TerminalWorkspace</c> would be a second connect path that got all of those
/// subtly wrong. What this method adds to that path is one thing: which tab the attempt belongs to.
/// </para>
/// <para>
/// <b>The old session is closed before the new one is asked for.</b> 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. Reconnecting without
/// closing it would leak one of each per reconnect, invisibly, inside the WebView.
/// </para>
/// <para>
/// <b>A host that asks for a typed password is sent to the keychain rather than refused.</b> The vault
/// raises its password sheet for one, and that sheet is drawn on the hosts screen — so this navigates
/// there instead of arming the tab, and the session that results is a new tab. That is a smaller wrong
/// than a button that appears to do nothing because the thing it raised is on a screen nobody is looking
/// at.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ReconnectTabAsync(TerminalTabViewModel? tab)
{
if (tab is null || Vault is not { } vault || !tab.CanOfferReconnect)
{
return;
}
if (HostFor(tab) is not { } host)
{
Refuse(tab, $"{tab.Label} is not in this keychain any more, so there is nothing here to dial again.");
return;
}
// Selected first, so that a reconnect started from the tab strip while another tab was showing puts
// the user in front of the attempt they just asked for.
SelectTab(tab);
// Before the question below, because the question is about the selection: SelectedHostAsksForAPassword
// reads whichever row is lit, and asking it about a host it has not been pointed at yet would answer
// for the previous one. The connect command assigns the same row again, so this is not a second
// selection — it is the same one, made early enough to be asked about.
vault.SelectedHost = host;
// The same pair of conditions ConnectToRowAsync itself branches on: a machine that needs a secret and
// has not been given one is a sheet being raised rather than a connection being made, and a sheet is
// drawn on a screen this surface is not.
if (vault.SelectedHostAsksForAPassword && vault.ConnectPassword.Length == 0)
{
ShowScreen(ShellScreen.Hosts);
// The command's own second half raises the sheet and says which machine it is for; running it
// rather than setting the flag here keeps that wording in one place.
await vault.ConnectToRowCommand.ExecuteAsync(host).ConfigureAwait(true);
return;
}
await RedialIntoAsync(vault, host, tab).ConfigureAwait(true);
}
/// <summary>Dials <paramref name="host"/> with the attempt bound to <paramref name="tab"/>.</summary>
/// <remarks>
/// Split from <see cref="ReconnectTabAsync"/> so that the arming, the dial and the old session's release
/// read as the one sequence they are — the refusals above it are a separate concern and each of them
/// returns before reaching here.
/// </remarks>
private async Task RedialIntoAsync(VaultViewModel vault, HostRowViewModel host, TerminalTabViewModel tab)
{
var previous = tab.SessionId;
reconnecting = tab;
bool dialled;
try
{
await vault.ConnectToRowCommand.ExecuteAsync(host).ConfigureAwait(true);
}
finally
{
// The field being gone means OnVaultConnectionStarting took it, which is the one and only proof
// that an attempt actually began. Asking the attempts map instead does not work: a handshake that
// has already finished — succeeded or been refused — has been taken out of it again, and in this
// suite's fakes that happens before this line is reached.
dialled = reconnecting is null;
// Cleared however it went, so a refusal the vault made before dialling cannot leave the field
// armed to capture somebody else's next connection into this tab.
reconnecting = null;
}
if (!dialled)
{
// The vault turned it down before starting one — a key that has left the keychain, a group
// binding that no longer resolves. Its own Status carries the reason and is drawn on a screen
// this surface is not, so it is repeated where the button was pressed. The old session is left
// alone: there is nothing to replace it with.
Refuse(
tab,
vault.Status is { Length: > 0 } refusal
? refusal
: $"{tab.Label} could not be dialled again.");
return;
}
if (previous != 0)
{
// After the attempt has started, not before: the pane the user was reading stays up for as long
// as there is nothing to replace it with, and closing it first would blank the rectangle for the
// length of a handshake.
await workspace.CloseSessionAsync(previous).ConfigureAwait(true);
}
}
/// <summary>Says why a reconnect is not happening, where the strip that offered it is looking.</summary>
/// <remarks>
/// On the tab rather than in a field of this shell's own, because it is a fact about that tab: switching
/// to another terminal and back should not lose it, and switching to a different one should not show it.
/// See <see cref="TerminalTabViewModel.EndedDetail"/>.
/// </remarks>
private void Refuse(TerminalTabViewModel tab, string reason)
{
tab.Status = reason;
RaiseSessionState();
}
/// <summary>
/// Marks a tab dead when its shell ends on its own.
/// </summary>
@@ -3764,27 +4370,36 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// gap rather than worth blocking this handler on the recorder's queue.
/// </para>
/// </remarks>
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
Dispatcher.UIThread.Post(() =>
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => post(() =>
{
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
{
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
{
tab.IsLive = false;
}
tab.Ended(e.Outcome.End, e.Outcome.Description);
RefreshConnectedHosts();
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
});
// Only for the tab being looked at, which is what these three already ask. The rectangle does not
// change — a dead tab keeps its pane and its scrollback — but the status bar under it does, and
// the reconnect beside CONNECTED appears from here.
RaiseSessionState();
}
RefreshConnectedHosts();
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
});
/// <summary>
/// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
/// </summary>
/// <remarks>
/// <para>
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
/// wrong than a dot that goes dark when the vault is reopened.
/// Matched on the keychain item's id, the same handle <see cref="HostFor"/> matches on and for the
/// reason given there: two hosts may share a name, and this list is read as "which machines am I on".
/// Lighting both dots for a name two machines answer to would say the user has a shell open on a host
/// they have never connected to — and it is the same match that decides whose pinned paths the sidebar
/// offers, which would then be another host's folders.
/// </para>
/// <para>
/// A tab with no item behind it — a machine reached by typing an address — lights nothing, which is
/// correct: there is no row here that it is a session on.
/// </para>
/// <para>
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
@@ -3807,14 +4422,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
foreach (var host in vault.Hosts)
{
host.IsConnected = Tabs.Any(
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
host.IsConnected = Tabs.Any(tab => tab.IsLive && tab.HostId == host.EntityId);
// The active tab's host, and only when it is actually connected: the sidebar is for a session
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
if (host.IsConnected
&& SelectedTab is { } selected
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
if (host.IsConnected && SelectedTab is { } selected && selected.HostId == host.EntityId)
{
connectedActiveHost = host;
}
@@ -3948,15 +4560,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
/// the same <see cref="HostFor"/> match <see cref="RefreshConnectedHosts"/> used to decide the row is
/// there to click — which is the point: the pins on screen and the host they are opened on come from one
/// answer to "which machine is this tab", and two hosts of the same name cannot pull them apart.
/// </remarks>
[RelayCommand]
private async Task OpenPinnedPathAsync(string path)
{
if (Vault is not { } vault
|| SelectedTab is not { } tab
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
if (HostFor(SelectedTab) is not { } host)
{
return;
}
@@ -4037,10 +4648,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void PinFolderFromSidebar()
{
if (Vault is not { } vault
|| SelectedTab is not { } tab
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
if (Vault is not { } vault || HostFor(SelectedTab) is not { } host)
{
return;
}
@@ -4073,10 +4681,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
{
if (Vault is not { } vault
|| tab is null
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
if (HostFor(tab) is not { } host)
{
return;
}
@@ -4125,7 +4730,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal string? SessionAddress => Surface switch
{
ShellSurface.Terminal => SelectedTab?.Address,
_ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null,
// Connected, or connected until the link went. A session that has just been lost still has a host
// worth naming — it is the one the strip is offering to dial again — and requiring IsConnected here
// collapsed the entire left half of the status bar at exactly the moment it had the most to say.
_ when IsTransfersShowing =>
Transfers.IsConnected || Transfers.IsConnectionLost ? Transfers.ConnectedTo : null,
_ => null,
};
@@ -4258,6 +4869,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
OnPropertyChanged(nameof(SessionAddress));
OnPropertyChanged(nameof(IsSessionConnected));
OnPropertyChanged(nameof(SessionEndedText));
OnPropertyChanged(nameof(SessionEndedDetail));
OnPropertyChanged(nameof(IsSessionDropped));
OnPropertyChanged(nameof(IsSessionIdle));
OnPropertyChanged(nameof(CanReconnectSelectedTab));
OnPropertyChanged(nameof(CanReconnectSession));
OnPropertyChanged(nameof(SessionEndedDismissLabel));
OnPropertyChanged(nameof(SessionElapsedText));
OnPropertyChanged(nameof(SessionCipher));
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
@@ -4281,6 +4899,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
or nameof(TransfersViewModel.IsConnected)
or nameof(TransfersViewModel.IsConnectionLost)
or nameof(TransfersViewModel.LostConnection)
or nameof(TransfersViewModel.Remote)
or nameof(TransfersViewModel.ConnectedTo)
or nameof(TransfersViewModel.ConnectedCipher)
@@ -229,6 +229,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
internal string Label { get; }
/// <summary>
/// The keychain item this tab was opened from, or null for a machine reached by typing an address.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>What the shell matches this tab back to a host row with, because the name will not do it.</b>
/// Nothing stops two hosts being called the same thing, and a team's vault beside a personal one makes it
/// ordinary — so a lookup on <see cref="Label"/> can answer with a different machine that happens to
/// share the name. The reconnect under a stopped tab <em>dials</em> whatever that lookup finds, which is
/// the one place being approximately right is dialling a stranger. See
/// <c>MainWindowViewModel.HostFor</c>.
/// </para>
/// <para>
/// <b>It survives a lock, which the row objects do not.</b> This is the item's sync identity rather than
/// an index into the list on screen — see <c>VaultItem&lt;TSecret&gt;.EntityId</c> — so a vault that is
/// locked and opened again rebuilds every row around the same ids, and a tab that outlived the vault
/// finds its host again. A rename finds it too, which a name match by definition cannot.
/// </para>
/// <para>
/// 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 honestly refused a reconnect rather than offered one that would dial a namesake.
/// </para>
/// </remarks>
internal Guid? HostId { get; init; }
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
internal string Address { get; }
@@ -351,6 +376,95 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
[ObservableProperty]
private bool isShowing;
/// <summary>
/// How the shell behind this tab ended, or null while it has not.
/// </summary>
/// <remarks>
/// Null covers three states that need no telling apart here — still connecting, still running, and a
/// connection that never happened — because the only thing anything asks this is which ending to word,
/// and none of the three has one.
/// </remarks>
internal SshSessionEnd? EndReason { get; private set; }
/// <summary>
/// The sentence the shell's end came with, or null while there is none.
/// </summary>
/// <remarks>
/// The same string the renderer wrote into the pane, so the detail under a status line and the line in
/// the scrollback are one sentence rather than two accounts that can drift.
/// </remarks>
internal string? EndDescription { get; private set; }
/// <summary>Whether this tab has a session that has stopped, and something to say about it.</summary>
internal bool HasEnded => State is TerminalTabState.Open && !IsLive;
/// <summary>
/// The sentence the strip under a stopped terminal prints: why a reconnect was refused if one just was,
/// and otherwise how the session ended.
/// </summary>
/// <remarks>
/// <para>
/// The refusal wins because it is the newer answer and it is about something the user just did. A tab
/// whose reconnect was turned down — the machine has left the keychain, the key it was bound to is gone —
/// would otherwise go on printing how the shell ended, which is true and is not what was just asked.
/// </para>
/// <para>
/// <see cref="Status"/> is where the refusal lands because that is already what this property means:
/// what the tab has to say for itself. It is empty for the whole of a session's life —
/// <see cref="Opened"/> clears it — so a non-empty one on a stopped tab can only be something that
/// happened after the shell did.
/// </para>
/// </remarks>
internal string? EndedDetail => Status is { Length: > 0 } spoken ? spoken : EndDescription;
/// <summary>Whether this tab's session ended because the connection broke rather than because the shell did.</summary>
/// <remarks>
/// The tab strip's dot binds this, so the two endings are told apart at a glance in the one place every
/// terminal is listed. They used to draw the same grey, which made a tab that had been cut off
/// indistinguishable from one somebody had closed down properly.
/// </remarks>
internal bool IsDropped => EndReason is SshSessionEnd.ConnectionLost;
/// <summary>
/// What the status bar says instead of CONNECTED, or null while nothing has ended.
/// </summary>
/// <remarks>
/// <para>
/// Two words rather than one, because the two endings are read completely differently. A shell that
/// exited is the ordinary end of a day's work and wants no alarm; a connection that broke is a machine
/// that may well still be there, and is the whole reason the reconnect beside it exists.
/// </para>
/// <para>
/// <see cref="SshSessionEnd.ClosedLocally"/> has no word here on purpose. This client closed the session,
/// which means a tab is being removed or reconnected — there is no moment at which somebody reads a
/// status line about it.
/// </para>
/// </remarks>
internal string? EndedSummary => EndReason switch
{
SshSessionEnd.ConnectionLost => "DISCONNECTED",
SshSessionEnd.RemoteClosed => "SHELL EXITED",
_ => null,
};
/// <summary>
/// Whether this tab is in a state where dialling again would mean something.
/// </summary>
/// <remarks>
/// <para>
/// True for a shell that ended however it ended, and for a connection that was refused. All three are a
/// tab with no live session and a machine it was pointed at — and offering the retry on a refusal is
/// worth as much as offering it on a drop, because the commonest refusal is a host that was not up yet.
/// </para>
/// <para>
/// <b>It says the tab is willing, not that the shell can do it.</b> Whether there is anything to dial —
/// a keychain entry under this tab's name, and one that does not need a password typed first — is the
/// shell's question, because this view model has never heard of a vault. See
/// <c>MainWindowViewModel.CanReconnectSelectedTab</c>.
/// </para>
/// </remarks>
internal bool CanOfferReconnect => HasEnded || IsFailed;
/// <summary>Whether there is a pane behind this tab.</summary>
internal bool HasSession => State is TerminalTabState.Open;
@@ -413,6 +527,13 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
{
SessionId = sessionId;
Status = string.Empty;
// Cleared before the flags below raise off them. A tab reconnecting carries one of these from the
// session that just ended, and a live terminal that still says how the last one stopped is the
// stale-fact failure this pair exists to remove.
EndReason = null;
EndDescription = null;
IsLive = true;
State = TerminalTabState.Open;
@@ -432,6 +553,12 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
Status = reason;
IsLive = false;
// A refusal is not an ending of the kind Ended records — there was never a session to end — so
// anything a previous attempt on this tab left behind goes with it. Without this, a reconnect that
// is itself refused would draw the refusal and the old shell's ending at once.
EndReason = null;
EndDescription = null;
// Before the state change, so the list is already correct the first time a view asks. The step that
// was running is where it stopped, and the ones behind it stay done: how far a refused connection
// got is the most useful thing the card still knows, and it is the difference between "that host is
@@ -447,6 +574,96 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
State = TerminalTabState.Failed;
}
/// <summary>
/// Records that the shell behind this tab has stopped, and how.
/// </summary>
/// <param name="end">Which of the three ways it ended.</param>
/// <param name="description">The sentence the pump wrote into the pane, for the detail line.</param>
/// <remarks>
/// <para>
/// The tab stays and its pane stays, exactly as before: the last thing the remote said is usually why the
/// shell ended, and the renderer has already written this same sentence into the scrollback. What is new
/// is that the tab now knows <em>which</em> ending it was, which is the difference between a shell that is
/// finished and a machine that is probably still there — see <see cref="CanOfferReconnect"/>.
/// </para>
/// <para>
/// Ignored for a tab that is connecting or failed. A session end can only be about a session, and a late
/// announcement for one this tab has already been reconnected past would otherwise reach back and mark a
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
/// </para>
/// </remarks>
internal void Ended(SshSessionEnd end, string description)
{
if (State is not TerminalTabState.Open)
{
return;
}
EndReason = end;
EndDescription = description;
IsLive = false;
OnPropertyChanged(nameof(EndedDetail));
OnPropertyChanged(nameof(EndedSummary));
OnPropertyChanged(nameof(EndDescription));
OnPropertyChanged(nameof(IsDropped));
}
/// <summary>
/// Puts this tab back to connecting, for an attempt that is about to be made on the same machine.
/// </summary>
/// <remarks>
/// <para>
/// The tab is reused rather than a second one opened beside it, because reconnecting is what a person
/// means when they say it: the tab they were working in comes back, in the place it was, still selected.
/// A new tab would leave the strip holding two entries for one machine, one of them a headstone.
/// </para>
/// <para>
/// <b>The session id is dropped here, and the pane it named is the caller's to reclaim.</b> Nothing in
/// this view model can send a frame; what it can do is stop claiming a session that is about to stop
/// being this tab's. See <c>MainWindowViewModel.ReconnectTabAsync</c>, which closes the old session for
/// its pane's sake — a pane holds an xterm, its scrollback and a WebGL context, and browsers hand out
/// about sixteen of the last.
/// </para>
/// <para>
/// The step list is rebuilt to how it looked at the start of the first attempt rather than left as it
/// was. A list still showing where the last attempt got to would read as progress this one had already
/// made, which is the one thing the card promises never to do.
/// </para>
/// </remarks>
internal void Reconnecting()
{
SessionId = 0;
EndReason = null;
EndDescription = null;
IsLive = false;
// Cleared with the session they were facts about. Keeping them would put the previous connection's
// cipher in the status bar beside a handshake that has not negotiated one yet.
StartedAt = null;
Cipher = null;
HostKeyAlgorithm = null;
IdentityLabel = null;
foreach (var row in Steps)
{
row.State = ConnectionStepState.Pending;
}
// The same opening position the constructor sets, and for the same reason: the renderer wait starts
// in this turn, so a list of five pending rows would show an attempt that had not begun.
Steps[0].State = ConnectionStepState.Running;
Status = Steps[0].Caption;
State = TerminalTabState.Connecting;
OnPropertyChanged(nameof(StepsDone));
OnPropertyChanged(nameof(EndedDetail));
OnPropertyChanged(nameof(EndedSummary));
OnPropertyChanged(nameof(EndDescription));
OnPropertyChanged(nameof(IsDropped));
}
/// <summary>Marks every step done, for a connection that is no longer being waited on.</summary>
private void CompleteSteps()
{
@@ -463,5 +680,24 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
OnPropertyChanged(nameof(HasSession));
OnPropertyChanged(nameof(IsConnecting));
OnPropertyChanged(nameof(IsFailed));
OnPropertyChanged(nameof(HasEnded));
OnPropertyChanged(nameof(CanOfferReconnect));
// Opened and Failed both clear the previous ending and then land here, so this is where a dot that
// was red for the last session goes back to whatever the new state deserves.
OnPropertyChanged(nameof(IsDropped));
}
/// <remarks>
/// <see cref="HasEnded"/> and <see cref="CanOfferReconnect"/> are both partly this flag, and the strip
/// binds them — so a shell that stops has to move them in the same notification that greys its dot.
/// </remarks>
/// <remarks>The strip under a stopped terminal reads this as well as the ending; see <see cref="EndedDetail"/>.</remarks>
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(EndedDetail));
partial void OnIsLiveChanged(bool value)
{
OnPropertyChanged(nameof(HasEnded));
OnPropertyChanged(nameof(CanOfferReconnect));
}
}
File diff suppressed because it is too large. Load diff
@@ -74,6 +74,22 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
/// </remarks>
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
/// <summary>How long the look taken on the way to a restart may take before it is given up on.</summary>
/// <remarks>
/// <para>
/// Ten seconds. It bounds one HTTPS request for a small JSON document, which ordinarily answers in well
/// under one — and the case it exists for is not a slow answer but no answer at all: a captive portal or
/// a firewall that drops packets rather than refusing them, where the underlying handler's own timeout is
/// a hundred seconds. A refused connection fails in milliseconds and never reaches this.
/// </para>
/// <para>
/// Bounded because somebody is waiting on it with a finger on RESTART NOW, which is the whole difference
/// from the timer's own pass. Giving up is not a failure here: the build already on disk is still newer
/// than the one running, so the restart goes ahead with it.
/// </para>
/// </remarks>
private static readonly TimeSpan LastLookTimeout = TimeSpan.FromSeconds(10);
private readonly IUpdateChannel updates;
private readonly ClientSettingsStore settings;
private readonly TimeProvider clock;
@@ -143,6 +159,18 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
[ObservableProperty]
private DateTimeOffset? lastChecked;
/// <summary>
/// Whether a restart is under way, including the look it takes before replacing anything.
/// </summary>
/// <remarks>
/// It exists to keep the banner up. Everything the banner shows keys off <see cref="IsReady"/>, and the
/// last look moves the state to Checking and possibly Downloading — so without this, pressing RESTART NOW
/// makes the strip vanish and leaves nothing on screen saying why, for however long a package takes. See
/// <see cref="ReadyHeadline"/>, which is what it says instead.
/// </remarks>
[ObservableProperty]
private bool isRestarting;
internal bool IsSupported => State is not UpdateState.Unsupported;
internal bool IsUnsupported => State is UpdateState.Unsupported;
@@ -155,12 +183,37 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
internal bool CanCheckNow => IsSupported && State is not (UpdateState.Checking or UpdateState.Downloading);
internal bool IsBannerShowing => IsReady && !IsBannerDismissed;
internal bool IsBannerShowing => (IsReady || IsRestarting) && !IsBannerDismissed;
/// <summary>What the banner says.</summary>
internal string ReadyHeadline => ReadyVersion is { Length: > 0 } version
? $"DodoSSH {version} is ready to install."
: "An update is ready to install.";
/// <remarks>
/// Three sentences rather than one, because a restart no longer necessarily starts with a build in hand:
/// it takes one last look first, and may find something newer than what was fetched six hours ago. That
/// look is short and the fetch behind it is not, so both say what they are doing — a strip that read
/// "ready to install" while it downloaded would be describing the wrong build, and a blank one would be
/// describing nothing.
/// </remarks>
internal string ReadyHeadline
{
get
{
if (IsRestarting && State is UpdateState.Checking)
{
return "Checking for a newer build before restarting…";
}
if (IsRestarting && State is UpdateState.Downloading)
{
return string.Create(
CultureInfo.CurrentCulture,
$"A newer build appeared. Fetching it before restarting — {DownloadPercent}%");
}
return ReadyVersion is { Length: > 0 } version
? $"DodoSSH {version} is ready to install."
: "An update is ready to install.";
}
}
/// <summary>What restarting costs, in the terms this application has already taught.</summary>
/// <remarks>
@@ -201,10 +254,24 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
OnPropertyChanged(nameof(CanCheckNow));
OnPropertyChanged(nameof(IsBannerShowing));
OnPropertyChanged(nameof(RestartWarning));
OnPropertyChanged(nameof(ReadyHeadline));
}
partial void OnIsBannerDismissedChanged(bool value) => OnPropertyChanged(nameof(IsBannerShowing));
partial void OnIsRestartingChanged(bool value)
{
OnPropertyChanged(nameof(IsBannerShowing));
OnPropertyChanged(nameof(ReadyHeadline));
}
/// <remarks>
/// The headline carries the percentage while a restart is fetching, so it has to be re-read as the bar
/// moves. Raised unconditionally rather than only while restarting, because a condition here would be a
/// second place that has to agree with the headline's own about when the percentage is in it.
/// </remarks>
partial void OnDownloadPercentChanged(int value) => OnPropertyChanged(nameof(ReadyHeadline));
partial void OnReadyVersionChanged(string? value) => OnPropertyChanged(nameof(ReadyHeadline));
partial void OnLastCheckedChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastCheckedSummary));
@@ -278,14 +345,32 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
/// <summary>One pass of the loop.</summary>
/// <remarks>
/// <para>
/// <b>Quiet by construction.</b> A pass that finds nothing writes nothing, and a pass that cannot reach
/// the forge writes nothing either — an unreachable release page is a laptop on a train, it is not news,
/// and it heals itself in six hours. The same discipline as <c>VaultViewModel.AutoSyncAsync</c>: what
/// nobody asked for may only speak when it has something to say.
/// </para>
/// <para>
/// ◆ <b>A pass still runs when one is already fetched, and this used to be the one state that stopped it
/// for good.</b> The guard read <c>Downloading or Ready</c>, on the reasonable-looking argument that
/// there is no point fetching what has already been fetched. What it actually did was stop <em>asking</em>
/// — so the moment anything was downloaded, the banner was pinned to that build for the rest of the
/// process's life, however many newer ones were published afterwards. On the nightly channel, where the
/// release is deleted and remade on every push to main, that is most of the time: the client offered the
/// build it happened to see first, installed it, restarted, and immediately offered the next one. Two
/// updates and two restarts to move one step.
/// </para>
/// <para>
/// Not fetching what is already fetched is still the rule, and <see cref="FetchAsync"/> is where it now
/// lives — by comparing versions rather than by refusing to look. Downloading is still excluded, because
/// a fetch in flight must not be restarted underneath itself; Checking is excluded for the same reason,
/// and it is a pressed CHECK NOW that a tick would otherwise land on top of.
/// </para>
/// </remarks>
internal async Task CheckOnceAsync(CancellationToken cancellationToken)
{
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
if (!IsAutomatic || State is UpdateState.Checking or UpdateState.Downloading)
{
return;
}
@@ -339,27 +424,91 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
}
}
/// <summary>Checks, and fetches whatever it finds.</summary>
/// <returns>The update found, or null.</returns>
private async Task<AvailableUpdate?> FetchAsync(CancellationToken cancellationToken)
/// <summary>Checks, and fetches whatever it finds that is not already fetched.</summary>
/// <param name="cancellationToken">Cancellation.</param>
/// <param name="checkWithin">
/// A bound on the check alone, or null for none. See <see cref="LastLookTimeout"/>. Deliberately not a
/// bound on the whole method: a package legitimately takes minutes, and a caller that wants a short
/// answer wants it about the <em>question</em>, not about the download the answer may lead to.
/// </param>
/// <returns>What is waiting to be installed once this has run, or null if nothing is.</returns>
/// <remarks>
/// <para>
/// ◆ <b>It answers with what is pending rather than with what the check returned, and the difference is
/// what makes a second pass safe.</b> A client running 1.0 that has fetched 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" is
/// not the same question as "there is something new". Comparing the two is what lets the loop keep
/// asking without fetching the same package every six hours, and it is what replaces the pending build
/// when the feed has moved on underneath it.
/// </para>
/// <para>
/// A build already fetched is never thrown away, including when a check answers null. Null means the
/// feed's newest is no newer than the build that is <em>running</em>, which says nothing about the one
/// on disk; that one is still newer than what is running, and withdrawing an offer somebody has already
/// been shown would be the worse answer.
/// </para>
/// </remarks>
private async Task<AvailableUpdate?> FetchAsync(
CancellationToken cancellationToken,
TimeSpan? checkWithin = null)
{
State = UpdateState.Checking;
Status = string.Empty;
var found = await updates.CheckAsync(cancellationToken).ConfigureAwait(true);
var found = await AskAsync(checkWithin, cancellationToken).ConfigureAwait(true);
LastChecked = clock.GetUtcNow();
if (found is null)
{
State = UpdateState.Idle;
// Answered with what is still pending rather than with nothing, which is what keeps a pressed
// CHECK NOW honest: "you are on the latest build" is true of the build that is running and false
// of the machine, if there is one downloaded and waiting to be it.
State = ready is null ? UpdateState.Idle : UpdateState.Ready;
return null;
if (ready is { } waiting)
{
Status = Waiting(waiting);
}
return ready;
}
if (ready is { } already
&& string.Equals(already.Version, found.Version, StringComparison.Ordinal))
{
// The ordinary answer for every pass after the first one that found something. Not fetched
// again, which is the rule the old Ready guard was reaching for and got by not asking at all.
State = UpdateState.Ready;
Status = Waiting(already);
return already;
}
return await FetchIntoReadyAsync(found, cancellationToken).ConfigureAwait(true);
}
/// <summary>Downloads one update and offers it.</summary>
/// <remarks>
/// Split from <see cref="FetchAsync"/> for length rather than for reuse — it has one caller — and the
/// division is a real one: above is which build to fetch, below is fetching it.
/// </remarks>
private async Task<AvailableUpdate> FetchIntoReadyAsync(
AvailableUpdate found,
CancellationToken cancellationToken)
{
var superseded = ready;
State = UpdateState.Downloading;
DownloadPercent = 0;
// Said while it downloads, and only when this displaces something already fetched. Both places that
// shows are places somebody is looking: a banner whose version changes under them, and a RESTART NOW
// that starts a download instead of a restart.
Status = superseded is { } stale
? $"DodoSSH {found.Version} appeared while {stale.Version} was waiting. Fetching it instead."
: string.Empty;
// Monotonic, and it has to be. Progress<T> delivers its callbacks by posting them to the captured
// synchronisation context rather than invoking them inline, so a report can arrive after the
// download has already returned — and an unguarded assignment then puts a stale smaller number
@@ -382,13 +531,43 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
DownloadPercent = 100;
ready = found;
ReadyVersion = found.Version;
// Undismissed, because this is a different build from the one that was dismissed. LATER was an
// answer about a version, and there is a new question.
IsBannerDismissed = false;
State = UpdateState.Ready;
Status = $"DodoSSH {found.Version} is downloaded and will run after a restart.";
Status = Waiting(found);
return found;
}
/// <summary>Asks the channel, giving up after <paramref name="within"/> if one was given.</summary>
/// <remarks>
/// <para>
/// The wait is bounded rather than the request cancelled, and that is not the same thing: the desktop
/// channel's own remark records that Velopack's check takes no token, so cancelling would be observed
/// only once the call had already returned. Waiting on it with a timeout is what actually gives an
/// answer back in ten seconds.
/// </para>
/// <para>
/// Which leaves the request running, and it is the reason the channel had to learn to remember what it
/// <em>downloaded</em> rather than only what it last found: an orphaned check can land afterwards and
/// move the channel's idea of the newest build, and the restart that gave up on it is about to name the
/// older one. See <c>VelopackUpdateChannel.Matched</c>.
/// </para>
/// </remarks>
private async Task<AvailableUpdate?> AskAsync(TimeSpan? within, CancellationToken cancellationToken)
{
var asking = updates.CheckAsync(cancellationToken);
return within is { } bound
? await asking.WaitAsync(bound, clock, cancellationToken).ConfigureAwait(true)
: await asking.ConfigureAwait(true);
}
private static string Waiting(AvailableUpdate update) =>
$"DodoSSH {update.Version} is downloaded and will run after a restart.";
/// <remarks>
/// <para>
/// Hands the update to whoever was given the job at composition rather than applying it here, and the
@@ -406,26 +585,89 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
[RelayCommand]
private async Task RestartNowAsync()
{
if (ready is not { } update)
if (ready is null)
{
return;
}
// ◆ Reported rather than allowed to escape, and the phone is why. Where applying ends the process
// a throw here is nearly unreachable; where it does not — see ApplyingEndsTheProcess — the whole
// point is that this returns and the application carries on, so an exception has somewhere to go:
// out of a command handler, past the dispatcher, and into the process. What that looks like to
// somebody holding the phone is pressing INSTALL and having the application close, with nothing
// installed and nothing said. Failed and the message is the same answer CheckNowAsync gives, and
// it is the difference between a bug that describes itself and one that presents as a crash.
IsRestarting = true;
try
{
await restart(update).ConfigureAwait(true);
await TakeOneLastLookAsync().ConfigureAwait(true);
if (ready is not { } update)
{
return;
}
// ◆ Reported rather than allowed to escape, and the phone is why. Where applying ends the
// process a throw here is nearly unreachable; where it does not — see ApplyingEndsTheProcess —
// the whole point is that this returns and the application carries on, so an exception has
// somewhere to go: out of a command handler, past the dispatcher, and into the process. What
// that looks like to somebody holding the phone is pressing INSTALL and having the application
// close, with nothing installed and nothing said. Failed and the message is the same answer
// CheckNowAsync gives, and it is the difference between a bug that describes itself and one
// that presents as a crash.
try
{
await restart(update).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
State = UpdateState.Failed;
Status = exception.Message;
}
}
finally
{
// Never reached on a desktop, where restart() replaces the process from inside the try. It is
// for the phone, where INSTALL is a request the platform may refuse, and for every failure
// path: a flag that keeps the banner up has to come down again.
IsRestarting = false;
}
}
/// <summary>
/// Asks the feed once more, on the way to replacing this process.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Because what was fetched can be hours old, and a restart is the one moment being one build
/// behind is expensive.</b> The loop asks every six hours; a build published between the last pass and
/// this press would otherwise be installed on the <em>next</em> restart, which is to say: update,
/// restart, be told at once that another update is waiting, update, restart again. Two of everything to
/// move one step, which is exactly what a rolling nightly channel produces.
/// </para>
/// <para>
/// It costs one small request in the ordinary case, because the feed answers with the build already on
/// disk and <see cref="FetchAsync"/> compares rather than downloads. It costs a download only when there
/// genuinely is something newer, which is the case where the alternative was two downloads anyway.
/// </para>
/// <para>
/// <b>Nothing here refuses a restart.</b> A forge that cannot be reached, a look that ran out of time, a
/// fetch of the newer build that failed halfway — all of them leave the build already on disk untouched
/// and still newer than the one running, so the restart goes ahead with it. That is why this swallows
/// rather than reporting: <see cref="UpdateState.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.
/// </para>
/// </remarks>
private async Task TakeOneLastLookAsync()
{
try
{
await FetchAsync(lifetime.Token, checkWithin: LastLookTimeout).ConfigureAwait(true);
}
catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
{
// Shutting down, which is the one case that must not go on to restart: whatever is fetched is
// applied at the next ordinary launch anyway, and that is the policy rather than a fallback.
ready = null;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
State = UpdateState.Failed;
Status = exception.Message;
// Including the bound running out. Back to the offer that was already on screen.
State = UpdateState.Ready;
}
}
@@ -55,6 +55,22 @@ internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count
/// <summary>Whether this heading has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>
/// What this heading is folded and unfolded by, in <c>VaultViewModel.collapsedGroups</c>.
/// </summary>
/// <remarks>
/// ◆ <b>The group's id for a group, and something else for "No group" — which is why this is not
/// <see cref="GroupId"/> with a fallback.</b> It was exactly that until the desktop's board grew a
/// heading per vault: there is now one "No group" heading <em>per vault</em>, and every one of them
/// answers <see cref="GroupId"/> with null. Keyed on <see cref="Guid.Empty"/> they were one heading as
/// far as the folded set was concerned, so folding one vault's unfiled hosts away folded every other
/// vault's with it. The desktop passes the vault's own id instead; the phone, whose list has a single
/// UNGROUPED heading spanning every vault, goes on passing <see cref="Guid.Empty"/>. Neither can collide
/// with a group id, and <see cref="Guid.Empty"/> is not a legal one — <c>HostSecret.TryValidate</c>
/// refuses it.
/// </remarks>
internal Guid CollapseKey { get; init; }
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
internal string Chevron => IsExpanded ? "▾" : "▸";
@@ -167,6 +183,31 @@ internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnly
internal bool HasHeader => header is not null;
internal IReadOnlyList<HostRowViewModel> Hosts => hosts;
/// <summary>
/// The vault this section opens, or empty where it is not the first section of one.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The board's outer level, and groups are the inner one.</b> A group belongs to exactly one vault
/// — it is an item stored under that vault's key, like the hosts filed into it — so a board that drew
/// every group in one run was flattening the containment that already exists. Two vaults each holding a
/// "production" made the point: they were two headings with the same word on them and a small grey
/// vault name beside each, distinguishable only by reading the second line. Vaults are now the headings
/// and groups sort the hosts inside one, which is the shape the data has.
/// </para>
/// <para>
/// Carried on the first section of each vault rather than as a section of its own, because a vault
/// heading is not a thing cards go under: every card is under a group heading, and the group headings
/// are what a vault contains. Empty on every other section, and empty on all of them where the board
/// draws one vault — a heading naming the only vault there is says nothing the screen does not already
/// say, and is the same test <see cref="HostRowViewModel.VaultBadge"/> has always applied to itself.
/// </para>
/// </remarks>
internal string VaultHeading { get; init; } = string.Empty;
/// <summary>Whether this section opens a vault and should draw its name above the group heading.</summary>
internal bool StartsAVault => VaultHeading.Length > 0;
}
/// <summary>An entry in the toolbar's Group ▾ filter flyout.</summary>
@@ -411,6 +452,19 @@ internal sealed partial class HostRowViewModel(
CultureInfo.InvariantCulture,
$"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");
/// <summary>
/// What a card says when the pointer rests on it: the name in full, and the address under it.
/// </summary>
/// <remarks>
/// ◆ <b>The name is here because the card trims it and the tooltip was the one place it was not said.</b>
/// A tile is a fixed 214 wide (see <c>Border.tile</c> in App.axaml) and the name is drawn with
/// <c>TextTrimming</c>, so anything past about twenty characters ends in an ellipsis — which is the
/// honest thing for the card to do and leaves the machine unidentifiable on a board of hosts whose names
/// share a prefix. The tooltip carried the address alone, which named the *host* but not the bookmark:
/// six servers behind one address differ only by the half that was trimmed away.
/// </remarks>
internal string Tooltip => $"{Label}\n{Address}";
/// <remarks>
/// An em dash for "nobody", which covers both a host that states no username and a host whose group
/// states none either. The two are the same thing to look at and the same thing at connect time: refused,
@@ -893,6 +947,7 @@ internal static class ItemBadge
/// <param name="identityLabel">
/// The display name of the key or credential that authenticated, or null when a typed password did.
/// </param>
/// <param name="hostId">The keychain item this was dialled from, or null for a typed-in address.</param>
internal sealed class TerminalSessionEventArgs(
Guid attemptId,
uint sessionId,
@@ -900,7 +955,8 @@ internal sealed class TerminalSessionEventArgs(
string address,
string cipher,
string hostKeyAlgorithm,
string? identityLabel) : EventArgs
string? identityLabel,
Guid? hostId) : EventArgs
{
/// <summary>Which attempt this session came out of.</summary>
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
@@ -917,19 +973,27 @@ internal sealed class TerminalSessionEventArgs(
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
internal string? IdentityLabel { get; } = identityLabel;
/// <inheritdoc cref="ConnectionAttemptEventArgs.HostId" />
internal Guid? HostId { get; } = hostId;
}
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">Who this will be logged in as, and where.</param>
/// <param name="hostId">The keychain item this is being dialled from, or null for a typed-in address.</param>
/// <remarks>
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
/// has no vault to read it from.
/// </remarks>
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs
internal sealed class ConnectionAttemptEventArgs(
Guid attemptId,
string label,
string address,
Guid? hostId) : EventArgs
{
/// <summary>Identifies this attempt for the whole of its life.</summary>
/// <remarks>
@@ -941,6 +1005,19 @@ internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, s
internal string Label { get; } = label;
internal string Address { get; } = address;
/// <summary>
/// The keychain item this connection was made from, or null for a machine that is not in the keychain.
/// </summary>
/// <remarks>
/// ◆ <b>The name is not an identifier and this is.</b> Two hosts may be called the same thing — nothing
/// stops it, and a team vault next to a personal one makes it ordinary — so a tab that remembered only
/// what it was called could be matched back to the wrong machine. That is not a cosmetic mix-up: the
/// reconnect below the tab dials whichever row the match found, so the one thing that must not be
/// guessed is which machine a reconnect goes to. Carried from here because a tab has no vault to ask —
/// see <c>TerminalTabViewModel.HostId</c>.
/// </remarks>
internal Guid? HostId { get; } = hostId;
}
/// <summary>A connection that has got as far as a named step.</summary>
@@ -1391,8 +1468,18 @@ internal sealed partial class VaultViewModel(
ServerReconnectHandler? reconnect = null,
Func<string, Task>? copyToClipboard = null,
ConnectionRecorder? connectionLog = null,
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
VaultVisibility? visibility = null,
ISshReachabilityProbe? reachability = null) : ObservableObject, IAsyncDisposable
{
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
/// <remarks>
/// Static and shared, because <see cref="SshReachabilityProbe"/> holds nothing but a budget and every
/// call to it is a fresh socket. Last in the parameter list and optional for the reason
/// <c>visibility</c> is: every existing caller — which is both heads and every test — goes on meaning
/// exactly what it did, and the one that wants to watch what was probed passes a substitute.
/// </remarks>
private static readonly ISshReachabilityProbe DefaultReachability = new SshReachabilityProbe();
/// <remarks>
/// <para>
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
@@ -5170,25 +5257,109 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(CollapseAllLabel));
}
/// <summary>
/// The board, vault by vault: every vault's own groups under its own heading.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Two levels, and the outer one is the vault.</b> See
/// <see cref="HostSectionViewModel.VaultHeading"/> for the argument. What it means here is a pass per
/// vault rather than one over every group at once: each vault's unfiled hosts and each vault's groups
/// are flattened on their own, and the first section that comes back wears the vault's name.
/// </para>
/// <para>
/// <b>One vault on the board and this is exactly what it was.</b> Not a special case for tidiness — a
/// heading naming the only vault there is would be a level of hierarchy with nothing to distinguish,
/// and the single-vault board is what most of this application's users have. The count is of vaults
/// that are readable <em>and</em> shown, so hiding the second vault takes the headings away with it.
/// </para>
/// </remarks>
private List<HostSectionViewModel> BuildHostSections()
{
var shown = Hosts.Where(MatchesHostBoardFilters).ToArray();
var flat = FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group");
var vaults = BoardVaults();
var sections = vaults.Count < 2
? SectionsFrom(
FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group"),
vaultHeading: null)
: vaults
.SelectMany(vault => SectionsFrom(
FlattenIntoSections(
[.. shown.Where(row => row.VaultId == vault.VaultId)],
ungroupedFirst: true,
ungroupedLabel: "No group",
vaultId: vault.VaultId),
vault.Name.ToUpperInvariant()))
.ToList();
// Marked on the first heading of the whole board, which is where the mock draws Collapse all /
// Expand all — see IsFirstBoardSection and the XAML. The first *heading* rather than the first
// section: a vault with no groups at all draws its cards under no heading, and there is nothing on
// that row for the control to sit on.
for (var index = 0; index < sections.Count; index++)
{
if (sections[index].Header is not { } first)
{
continue;
}
sections[index] = new HostSectionViewModel(
first with { IsFirstBoardSection = true },
sections[index].Hosts)
{
VaultHeading = sections[index].VaultHeading,
};
break;
}
return sections;
}
/// <summary>The vaults the board draws a heading for, in the order it draws them.</summary>
/// <remarks>
/// The active vault first and then by name, which is the order <see cref="Groups"/>, the host list and
/// the key list are all already sorted into — so the sections come out in the order the rows inside them
/// were. Hidden vaults are left out here rather than filtered later, because their absence is what makes
/// the headings disappear when one vault is left. See <see cref="IsVaultShown"/>.
/// </remarks>
private List<(Guid VaultId, string Name)> BoardVaults() =>
[
.. session.ReadableVaults
.Where(vault => IsVaultShown(vault.VaultId))
.OrderByDescending(vault => vault.VaultId == session.ActiveVaultId)
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
.Select(vault => (vault.VaultId, vault.Name)),
];
/// <summary>
/// Regroups one flattened run — a whole board, or one vault's part of it — into sections.
/// </summary>
/// <param name="flat">Headings and rows in draw order, from <see cref="FlattenIntoSections"/>.</param>
/// <param name="vaultHeading">
/// The vault's name for the first section, or null where the board is not drawing vault headings. When
/// it is given, the group headings drop their own vault badge: the name is already on the row above
/// them, and printing it twice on every heading is how a two-level board reads as a cluttered one.
/// </param>
/// <remarks>
/// Rows arriving before any heading are the no-groups case — <see cref="FlattenIntoSections"/> hands
/// back the hosts alone — and they become one section with a null header, which is what the board's XAML
/// reads as "draw the cards and nothing above them". Per vault now rather than for the whole board: a
/// vault nobody has made a group in draws its cards flat, under its own name, beside a vault that has.
/// </remarks>
private static List<HostSectionViewModel> SectionsFrom(List<ISidebarRow> flat, string? vaultHeading)
{
var sections = new List<HostSectionViewModel>();
if (flat.Count == 0)
{
return [];
return sections;
}
if (Groups.Count == 0)
{
return [new HostSectionViewModel(null, [.. flat.Cast<HostRowViewModel>()])];
}
var sections = new List<HostSectionViewModel>();
SidebarGroupHeader? header = null;
var members = new List<HostRowViewModel>();
var firstHeading = true;
var loose = new List<HostRowViewModel>();
foreach (var row in flat)
{
@@ -5199,15 +5370,12 @@ internal sealed partial class VaultViewModel(
sections.Add(new HostSectionViewModel(header, members));
}
// Marked on the first heading only, which is where the mock draws Collapse all / Expand
// all — see IsFirstBoardSection and the XAML.
header = firstHeading ? next with { IsFirstBoardSection = true } : next;
firstHeading = false;
header = vaultHeading is null ? next : next with { VaultBadge = string.Empty };
members = [];
}
else if (row is HostRowViewModel host)
{
members.Add(host);
(header is null ? loose : members).Add(host);
}
}
@@ -5215,6 +5383,18 @@ internal sealed partial class VaultViewModel(
{
sections.Add(new HostSectionViewModel(header, members));
}
else
{
sections.Add(new HostSectionViewModel(null, loose));
}
if (vaultHeading is { Length: > 0 })
{
sections[0] = new HostSectionViewModel(sections[0].Header, sections[0].Hosts)
{
VaultHeading = vaultHeading,
};
}
return sections;
}
@@ -5230,6 +5410,12 @@ internal sealed partial class VaultViewModel(
/// desktop's first, and there is no reason the two heads have to agree once there are two callers.
/// </param>
/// <param name="ungroupedLabel">What that heading says — "UNGROUPED" on the phone, "No group" here.</param>
/// <param name="vaultId">
/// The vault this run is about, or null for a run over every vault at once. Given, it narrows the
/// groups to that vault's own and becomes the fold key of its ungrouped heading — see
/// <see cref="SidebarGroupHeader.CollapseKey"/>, which exists because there is now one such heading per
/// vault. The phone's single list passes null and is unchanged by any of this.
/// </param>
/// <remarks>
/// <b>No groups means no headings</b>, on both heads and for the same reason: a keychain nobody has
/// filed anything in should look exactly as it did before groups existed, on a screen that draws
@@ -5240,17 +5426,27 @@ internal sealed partial class VaultViewModel(
/// left empty.
/// </remarks>
private List<ISidebarRow> FlattenIntoSections(
IReadOnlyList<HostRowViewModel> shown, bool ungroupedFirst, string ungroupedLabel)
IReadOnlyList<HostRowViewModel> shown,
bool ungroupedFirst,
string ungroupedLabel,
Guid? vaultId = null)
{
var rows = new List<ISidebarRow>();
if (Groups.Count == 0)
// This vault's groups where a vault was named, every readable vault's where one was not. A group is
// an item of one vault, so a run about one vault has no business drawing another's headings — and a
// vault nobody has made a group in falls into the case below, drawing its cards flat.
var groups = vaultId is { } only
? Groups.Where(group => group.VaultId == only).ToList()
: [.. Groups];
if (groups.Count == 0)
{
rows.AddRange(shown);
return rows;
}
var known = Groups.Select(group => group.EntityId).ToHashSet();
var known = groups.Select(group => group.EntityId).ToHashSet();
void AddUngrouped() => AddFlatSection(
rows,
@@ -5258,14 +5454,15 @@ internal sealed partial class VaultViewModel(
null,
host => host.Host.GroupId is not { } id || !known.Contains(id),
ungroupedLabel,
onlyWhenOccupied: true);
onlyWhenOccupied: true,
ungroupedKey: vaultId ?? Guid.Empty);
if (ungroupedFirst)
{
AddUngrouped();
}
foreach (var group in Groups)
foreach (var group in groups)
{
AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel);
}
@@ -5289,13 +5486,19 @@ internal sealed partial class VaultViewModel(
/// <param name="belongs">Which of the shown hosts fall under it.</param>
/// <param name="ungroupedLabel">What the heading says when <paramref name="group"/> is null.</param>
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
/// <param name="ungroupedKey">
/// What the ungrouped heading folds under, where this is one — the vault's id on the desktop's
/// per-vault board, <see cref="Guid.Empty"/> on the phone's single list. See
/// <see cref="SidebarGroupHeader.CollapseKey"/>.
/// </param>
private void AddFlatSection(
List<ISidebarRow> rows,
IReadOnlyList<HostRowViewModel> shown,
HostGroupRowViewModel? group,
Func<HostRowViewModel, bool> belongs,
string ungroupedLabel,
bool onlyWhenOccupied = false)
bool onlyWhenOccupied = false,
Guid ungroupedKey = default)
{
var members = shown.Where(belongs).ToArray();
@@ -5304,7 +5507,8 @@ internal sealed partial class VaultViewModel(
return;
}
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty);
var key = group?.EntityId ?? ungroupedKey;
var expanded = !collapsedGroups.Contains(key);
rows.Add(new SidebarGroupHeader(
group?.EntityId,
@@ -5313,6 +5517,7 @@ internal sealed partial class VaultViewModel(
expanded)
{
VaultBadge = group?.VaultBadge ?? string.Empty,
CollapseKey = key,
});
if (!expanded)
@@ -5458,7 +5663,7 @@ internal sealed partial class VaultViewModel(
return;
}
var key = header.GroupId ?? Guid.Empty;
var key = header.CollapseKey;
if (!collapsedGroups.Remove(key))
{
@@ -5490,7 +5695,7 @@ internal sealed partial class VaultViewModel(
foreach (var header in headers)
{
var key = header.GroupId ?? Guid.Empty;
var key = header.CollapseKey;
if (collapsing)
{
@@ -6259,6 +6464,69 @@ internal sealed partial class VaultViewModel(
ApplyTheChosenHosts();
}
/// <summary>
/// A Ctrl-click: the card joins or leaves the set, and the set starts at whatever card is lit.
/// </summary>
/// <param name="row">The card that was Ctrl-clicked.</param>
/// <remarks>
/// <para>
/// ◆ <b>The lit card was not in the set, and every list this one is modelled on says it should be.</b>
/// The board always has a selection — a fresh unlock lights the first host so that CONNECT has something
/// under it, see <see cref="SelectionAfterReload"/> — so "one card lit, nothing ticked" is the state
/// this screen opens in, not an edge case. Ctrl-clicking a second card there used to produce a set of
/// one: the machine the user was looking at was silently left out, and the count said 1 while two cards
/// were marked on screen. Delete, Move and Change group all read the set, so the omission was one the
/// user could act on without noticing.
/// </para>
/// <para>
/// Only into an <em>empty</em> set, and never the card being clicked. The first is what makes it a
/// starting point rather than a card that keeps re-adding itself: once anything is ticked the lit card
/// is either already in the set or was deliberately taken out of it. The second is what keeps every
/// Ctrl-click visibly doing something — folding the lit card in and then toggling that same card off
/// again would leave the set empty and the card still lit, which is a gesture with no effect and no way
/// to tell it was heard.
/// </para>
/// <para>
/// Separate from <see cref="ToggleHostChoice"/> rather than folded into it, because that one is also the
/// phone's tap and the phone has no lit card to fold: there a tap only ever reaches the set once a long
/// press has already put something in it, and a selection nothing draws is not one anybody can be
/// surprised to find in a count.
/// </para>
/// </remarks>
[RelayCommand]
private void ExtendHostChoice(HostRowViewModel? row)
{
if (row is null)
{
return;
}
FoldTheLitCardIn(besides: row);
ToggleHostChoice(row);
}
/// <summary>Puts the lit card into an empty set, which is what a Ctrl gesture builds on.</summary>
/// <param name="besides">A card to leave out — the one the gesture is about. See
/// <see cref="ExtendHostChoice"/> for why.</param>
/// <remarks>
/// The board's order rather than <see cref="Hosts"/>, for the reason Ctrl+A takes it from there: a
/// selected host inside a folded section is not on screen, and starting a set with a card nobody can see
/// is the version of this that ends in a deletion. <see cref="ApplyTheChosenHosts"/> is left to the
/// caller, which runs it once for the whole gesture.
/// </remarks>
private void FoldTheLitCardIn(HostRowViewModel? besides = null)
{
if (chosenHostIds.Count > 0
|| SelectedHost is not { } lit
|| ReferenceEquals(lit, besides)
|| !HostBoardOrder.Contains(lit))
{
return;
}
chosenHostIds.Add(lit.EntityId);
}
/// <summary>
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
/// </summary>
@@ -6288,6 +6556,14 @@ internal sealed partial class VaultViewModel(
{
chosenHostIds.Clear();
}
else
{
// A band dragged with Ctrl adds to what was already ticked, and on this board "what was already
// ticked" starts at the card the grid has lit. Same rule as a Ctrl-click; see ExtendHostChoice
// for the whole of the argument. No card is left out here, because a band adds rather than
// toggles and cannot undo its own fold.
FoldTheLitCardIn();
}
foreach (var row in rows)
{
@@ -8283,12 +8559,25 @@ internal sealed partial class VaultViewModel(
/// Stores several hosts at once, the way one save does, with the keys they named where those are coming
/// too.
/// </summary>
/// <param name="requests">The hosts to store, and the key files any of them named.</param>
/// <param name="vaultId">
/// Which vault to write them into, or null for the active one.
/// </param>
/// <param name="cancellationToken">Cancellation.</param>
/// <remarks>
/// <para>
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
/// through the same repositories, the same outbox and the same automatic push as saving one — the import
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host or a key to
/// be written and no second place for the sync wiring to be forgotten.
/// For the two importers, which are the only things that produce hosts in bulk. It goes through the same
/// repositories, the same outbox and the same automatic push as saving one — an import screen decides
/// <em>which</em> hosts and <em>where</em> and nothing else, so there is no second way for a host or a
/// key to be written and no second place for the sync wiring to be forgotten.
/// </para>
/// <para>
/// ◆ <b>The vault is a parameter rather than the active one, and null still means the active one.</b>
/// The <c>ssh_config</c> importer reads a file belonging to this machine's own account and passes null;
/// the Hetzner one reads somebody's estate and offers a picker, because forty servers belonging to a
/// team are exactly the case where "wherever I happen to be standing" is the wrong answer. Everything
/// written by one call goes to one vault — the hosts and any key material with them — so a batch cannot
/// end up split across two.
/// </para>
/// <para>
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
@@ -8315,10 +8604,12 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal async Task<ImportOutcome> ImportHostsAsync(
IReadOnlyList<ImportedHostRequest> requests,
Guid? vaultId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(requests);
var target = vaultId ?? session.ActiveVaultId;
var hosts = 0;
var keys = 0;
var refused = 0;
@@ -8340,7 +8631,8 @@ internal sealed partial class VaultViewModel(
continue;
}
var (keyId, created) = await BindImportedKeyAsync(request, bound, cancellationToken)
var (keyId, created) = await BindImportedKeyAsync(
request, bound, target, cancellationToken)
.ConfigureAwait(true);
if (created)
@@ -8351,7 +8643,7 @@ internal sealed partial class VaultViewModel(
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
await session.Hosts
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
.CreateAsync(target, host, cancellationToken)
.ConfigureAwait(true);
hosts++;
@@ -8381,6 +8673,7 @@ internal sealed partial class VaultViewModel(
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
ImportedHostRequest request,
Dictionary<string, Guid> bound,
Guid vaultId,
CancellationToken cancellationToken)
{
if (request.Key is not { } key || request.KeyPath is not { } path)
@@ -8407,7 +8700,7 @@ internal sealed partial class VaultViewModel(
}
var id = await session.SshKeys
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
.CreateAsync(vaultId, key, cancellationToken)
.ConfigureAwait(true);
bound[path] = id;
@@ -8482,6 +8775,12 @@ internal sealed partial class VaultViewModel(
// destroys it, is not a pane anybody should have to read carefully.
PendingDeletion = null;
movingHostId = row.EntityId;
// ◆ The panel is drawn in the drawer's footer and nowhere else, so raising it means raising the
// drawer. Free when this was the drawer's own ⋯ menu — the pane is already open there — and the
// whole of what makes the board's context menu entry work, since a right click on a card does not
// open anything. Set before the flag, so the pane and its footer arrive in one layout pass.
IsHostPaneOpen = true;
IsMovingHost = true;
Status = string.Empty;
}
@@ -10967,7 +11266,52 @@ internal sealed partial class VaultViewModel(
/// </remarks>
private sealed class PhaseReporter(Action<SshConnectionPhase> report) : IProgress<SshConnectionPhase>
{
public void Report(SshConnectionPhase value) => report(value);
private int furthest = -1;
/// <summary>
/// The furthest phase reported, or null if the handshake never reported one at all.
/// </summary>
/// <remarks>
/// <para>
/// Kept because a failure cannot be read without it: <see cref="ExplainAsync"/> probes the endpoint
/// only for a connection that stopped while reaching, and this is the record of how far it got.
/// Nothing else asks — the card is driven by the events, not by this.
/// </para>
/// <para>
/// ◆ <b>An <see cref="int"/> behind a <see cref="Volatile"/> pair, because the write and the read are
/// on different threads.</b> Phases are reported from whichever thread the handshake is on — SSH.NET
/// raises host key verification on its own message thread, which is where
/// <see cref="SshConnectionPhase.CheckingHostKey"/> and
/// <see cref="SshConnectionPhase.Authenticating"/> come from — and this is read on the UI thread
/// after the attempt has failed. The <c>await</c> in between orders them in practice; nothing in the
/// language says so, and a cached read here would probe a host that had already offered a key.
/// </para>
/// <para>
/// <b>Monotonic, so a phase cannot go backwards.</b> The enum only ever advances today, and the
/// maximum rather than the latest is what keeps that an observation rather than something this type
/// relies on: a rekey mid-handshake raising <c>HostKeyReceived</c> a second time must not be able to
/// walk the record back to <see cref="SshConnectionPhase.CheckingHostKey"/> from
/// <see cref="SshConnectionPhase.OpeningShell"/>.
/// </para>
/// </remarks>
public SshConnectionPhase? Furthest =>
Volatile.Read(ref furthest) is var seen && seen < 0 ? null : (SshConnectionPhase)seen;
public void Report(SshConnectionPhase value)
{
var reported = (int)value;
// Read-compare-write rather than an interlocked maximum, which the BCL has no primitive for. The
// race it loses is two phases reported at the same instant from two threads, which this
// handshake does not do — it is one connection making one call at a time — and the cost of
// losing it would be one phase forgotten rather than a wrong answer.
if (reported > Volatile.Read(ref furthest))
{
Volatile.Write(ref furthest, reported);
}
report(value);
}
}
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
@@ -11005,7 +11349,8 @@ internal sealed partial class VaultViewModel(
var attempt = new ConnectionAttemptEventArgs(
Guid.CreateVersion7(),
target.Label,
Dialled(target, authentication));
Dialled(target, authentication),
target.HostId);
ConnectionStarting?.Invoke(this, attempt);
Status = $"Connecting to {target.Label}…";
@@ -11240,9 +11585,13 @@ internal sealed partial class VaultViewModel(
HostAuthentication authentication,
CancellationToken cancellationToken)
{
// Built here rather than inside the call, because the catch blocks below read it: how far the
// handshake got is what says whether a failure is worth probing the endpoint over. See ExplainAsync.
var reporter = ReporterFor(attempt);
try
{
await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken)
await ConnectAndAnnounceAsync(attempt, target, authentication, reporter, cancellationToken)
.ConfigureAwait(true);
}
catch (TimeoutException)
@@ -11268,10 +11617,87 @@ internal sealed partial class VaultViewModel(
catch (Exception exception)
{
RecordFailure(target, authentication, ConnectionOutcome.Failed);
Abandon(attempt, exception.Message);
Abandon(
attempt,
await ExplainAsync(exception, target, reporter, cancellationToken).ConfigureAwait(true));
}
}
/// <summary>
/// What to tell the user about a connection that failed, having gone and looked when the message alone
/// does not say.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Because "Connection has timed out." is what SSH.NET says for two unrelated faults.</b> A machine
/// that never answered and a machine that answered TCP and then never spoke SSH arrive here as the same
/// five words, and they have nothing in common to do about them — the first is a network, a firewall or a
/// wrong address, the second is an <c>sshd</c> that is wedged or a port that is not SSH. That was the
/// whole of what this screen could say about the failure users hit most, so it now goes and finds out;
/// see <see cref="ISshReachabilityProbe"/> for what it does and deliberately does not do.
/// </para>
/// <para>
/// <b>Only for a failure that happened while reaching, and the phase is what settles that.</b> The window
/// is exactly <see cref="SshConnectionPhase.Reaching"/>, and it is closed at both ends. Past it the
/// machine has demonstrably been reached — it offered a host key — so a refused password or a key the
/// remote would not take is not a reachability question, and probing the port would answer one nobody
/// asked while telling somebody their network is fine when they already know.
/// </para>
/// <para>
/// ◆ <b>And before it, nothing has been dialled yet, which is the half that would lie.</b> No phase at
/// all means the attempt never reached <c>ConnectAsync</c>: a renderer that did not attach, a credential
/// that would not decrypt, a private key that would not parse — <c>BuildConnectionInfo</c> throws on that
/// last one before the factory reports anything. Probing there finds a healthy port and appends "the SSH
/// handshake is what did not finish" to a message about an unreadable key, which is a confident wrong
/// answer where there was an accurate one. The phases are reported at the instant each begins and are not
/// guesses; see the enum.
/// </para>
/// <para>
/// <b>The original message is kept and the finding is added to it.</b> What SSH.NET said is the fact, and
/// this is a reading of a second measurement — dropping the first would leave nothing to check the second
/// against, and would lose the one case where the exception is already specific.
/// </para>
/// <para>
/// <b>A probe that itself fails changes nothing.</b> The user is being told about a connection that did
/// not happen; a diagnostic that cannot run is not a second failure worth reporting, and must not be able
/// to replace the first with its own exception. Cancellation is in that: a user who closed the tab while
/// this ran is not waiting for the answer.
/// </para>
/// </remarks>
private async Task<string> ExplainAsync(
Exception exception,
ConnectionTarget target,
PhaseReporter reporter,
CancellationToken cancellationToken)
{
if (reporter.Furthest != SshConnectionPhase.Reaching)
{
return exception.Message;
}
try
{
var report = await Reachability
.ProbeAsync(target.Hostname, target.Port, cancellationToken)
.ConfigureAwait(true);
// A probe with nothing to say leaves the message exactly as it was, rather than appending a
// space to it. The real one always has a sentence; this is what keeps a substitute that does not
// from changing the text of every failure that goes past it.
return report.Summary is { Length: > 0 } finding
? $"{exception.Message} {finding}"
: exception.Message;
}
catch (Exception probe) when (probe is not OutOfMemoryException)
{
return exception.Message;
}
}
/// <inheritdoc cref="DefaultReachability" />
private ISshReachabilityProbe Reachability => reachability ?? DefaultReachability;
/// <summary>What a renderer that never attached is reported as.</summary>
/// <remarks>
/// <para>
@@ -11337,6 +11763,7 @@ internal sealed partial class VaultViewModel(
ConnectionAttemptEventArgs attempt,
ConnectionTarget target,
HostAuthentication authentication,
PhaseReporter reporter,
CancellationToken cancellationToken)
{
// Not reported before the await: the tab is constructed with this step already running — see
@@ -11350,7 +11777,7 @@ internal sealed partial class VaultViewModel(
authentication.Credential);
var sessionId = await workspace
.OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
.OpenSessionAsync(request, TerminalSize.Default, reporter, cancellationToken)
.ConfigureAwait(true);
// The workspace has already opened a ticket for this session, with the address and the moment it
@@ -11381,7 +11808,8 @@ internal sealed partial class VaultViewModel(
Dialled(target, authentication),
facts?.Cipher ?? string.Empty,
facts?.HostKeyAlgorithm ?? string.Empty,
authentication.IdentityLabel));
authentication.IdentityLabel,
target.HostId));
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
// for must not wait on a vault write to appear.
@@ -33,11 +33,15 @@ const SERVER_SESSION_ACTIVATED = 4;
const SERVER_SESSION_REMOVED = 5;
const SERVER_PASTE = 6;
const SERVER_FONT_SIZE = 7;
const SERVER_CLIPBOARD = 8;
const SERVER_OUTPUT_DROPPED = 9;
const CLIENT_INPUT = 1;
const CLIENT_ACKNOWLEDGE = 2;
const CLIENT_RESIZE = 3;
const CLIENT_FONT_SIZE_STEP = 4;
const CLIENT_CLIPBOARD_COPY = 5;
const CLIENT_CLIPBOARD_PASTE = 6;
const HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000;
@@ -279,6 +283,66 @@ function handleKey(event) {
return true;
}
/*
── THE RIGHT-CLICK IS A COPY OR A PASTE, AND NEVER A MENU ───────────────────────────────────────────
What a terminal's right button has meant since the Windows console got QuickEdit and since PuTTY
shipped: with something selected it copies and drops the selection, with nothing selected it pastes.
No menu, no second click. This page used to answer with the WebView's own context menu — xterm stuffs
its helper textarea with the selection on contextmenu precisely so that the browser's Copy entry
works — which is correct behaviour for a web page and wrong for a terminal.
◆ 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 the font-size chords do — see requestFontSizeStep. Writing would have
worked from here on Windows, and does not go that way either: 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.
◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.
preventDefault alone would suppress the menu and still let xterm's own contextmenu handler run, which
moves the 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; with no menu they are an invisible textarea
floating over the grid and a DOM selection nothing will clear. Taking the event in the capture phase
and stopping it there is what keeps the gesture wholly ours.
rightClickSelectsWord is off for the same reason from the other side — see createSession.
*/
function handleRightClick(event, sessionId) {
event.preventDefault();
event.stopPropagation();
const session = sessions.get(sessionId);
if (!session) {
return;
}
const selection = session.term.getSelection();
if (selection.length > 0) {
send(CLIENT_CLIPBOARD_COPY, sessionId, new TextEncoder().encode(selection));
/*
And the selection goes, which is the console's behaviour and is load-bearing rather than cosmetic.
It is the only acknowledgement there is that the copy happened — nothing else on screen moves — and
it is what makes the *next* right-click a paste. Left standing, a selection somebody had forgotten
about would turn every later right-click into a copy of it, and the paste would look broken.
*/
session.term.clearSelection();
} else {
send(CLIENT_CLIPBOARD_PASTE, sessionId);
}
// Right-clicking a pane is a way of arriving at it. The caret follows, for the reason the paste frame's
// own focus() call gives.
session.term.focus();
}
/**
* Builds a pane for a session, or returns the one already there.
*
@@ -302,6 +366,19 @@ function createSession(sessionId) {
convertEol: false,
cursorBlink: true,
fontSize,
/*
Off, and on macOS that is a change from the default rather than a restatement of it: xterm defaults
this to true there, so a right-click would select the word under the pointer. With the gesture
meaning copy-or-paste, a right-click that quietly makes a selection is a right-click that copies a
word instead of pasting — and only on one platform, which is the worst way for it to be wrong.
Belt and braces today, because handleRightClick takes the event in the capture phase and xterm never
sees it. It is here for the upgrade that moves xterm's handler to mousedown, which is where it
already is on the Firefox path.
*/
rightClickSelectsWord: false,
scrollback: SCROLLBACK_LINES,
// Matches terminal.css, so the canvas and the page agree on the background. Both are the v2
// design's terminal surface — `TerminalSurface` in Palette.axaml. See the note in terminal.css.
@@ -346,6 +423,10 @@ function createSession(sessionId) {
term.attachCustomKeyEventHandler(handleKey);
// On the pane rather than on xterm's own element, and in the capture phase, which is what puts this
// ahead of xterm's handler on the element inside it. See handleRightClick.
pane.addEventListener('contextmenu', (event) => handleRightClick(event, sessionId), true);
term.onData((data) => {
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
});
@@ -454,6 +535,24 @@ function handleFrame(buffer) {
break;
}
case SERVER_OUTPUT_DROPPED: {
const session = sessions.get(sessionId) ?? createSession(sessionId);
/*
The host could not keep the whole of a flood, and this is the line saying where the gap is. It
goes in exactly here, between the bytes either side of it — the socket keeps order within a
session, so nothing has to be done to place it.
Written without an acknowledgement, deliberately. Credit describes what the remote sent and what
this page has drawn of it; these bytes came from the host itself, and acknowledging them would
hand back credit for output that was never reserved against the window in the first place. The
payload arrives already styled and already terminated by a reset — see the host's own remark for
why the reset comes first as well.
*/
session.term.write(payload);
break;
}
case SERVER_SESSION_ACTIVATED: {
const session = sessions.get(sessionId);
@@ -562,6 +661,35 @@ function handleFrame(buffer) {
break;
}
case SERVER_CLIPBOARD: {
const session = sessions.get(sessionId);
if (!session || payload.length < 1) {
break;
}
/*
paste() unconditionally, and that is the whole difference from SERVER_PASTE above. This text came
off the system clipboard, which is the case bracketed paste is *for*: readline uses the markers to
decide it has been pasted into, and a shell that gets a multi-line command inside them treats every
newline as text rather than as "run this". The reasoning that makes a single-line *snippet* worth
typing instead — bash marking a bracketed paste as an active region, so the command sits at the
prompt looking selected — is the reasoning for a snippet reading as though it had been typed. A
clipboard paste is not pretending to be typing, and every other terminal highlights it too.
No execute flag, either: a paste inserts and stops. Pressing Enter for somebody is what the
sidebar's RUN does, and it is a thing they asked for by name.
*/
session.term.paste(new TextDecoder().decode(payload));
// The caret goes where the text landed, for the reason SERVER_PASTE's own focus() call gives — half
// of it, anyway. Here the other half is already right: the gesture that asked for this was a click
// on the pane, so the host's window never took Win32 focus away.
session.term.focus();
break;
}
case SERVER_FONT_SIZE: {
if (payload.length < 1) {
break;
@@ -808,4 +936,15 @@ document.addEventListener('visibilitychange', () => {
window.addEventListener('focus', reconnectIfDown);
window.addEventListener('online', reconnectIfDown);
/*
And nowhere on this page has a context menu, which the panes' own handler nearly finishes on its own: they
are positioned at inset 0, so every right-click over a terminal lands on one and is stopped there before it
reaches here.
What is left is the status banner, which is drawn over the bottom of the pane while it has something to say
— so a right-click on "Reconnecting the terminal view…" would have been the one place in a terminal window
that still opened a browser menu. Prevented and not stopped: there is nothing below this to stop it from.
*/
document.addEventListener('contextmenu', (event) => event.preventDefault());
connect();
+8 -2
View File
@@ -24,5 +24,11 @@ These are the UMD builds, so they attach to `globalThis`: `Terminal`, `FitAddon.
1. Download the four files at the new versions and update the table above.
2. Confirm the UMD global names are unchanged.
3. Run the terminal suite, then start the app and open a session: nothing here is covered by the
.NET tests, because the renderer they exercise is a `ClientWebSocket` standing in for this page.
3. Confirm the right-click still belongs to `terminal.js`. Two things about xterm are load-bearing for it
and neither is a global: `rightClickSelectsWord` has to still be an option — the page sets it false, and
an option xterm no longer knows is silently ignored — and xterm has to still register its own right-click
handling on `contextmenu`, which `handleRightClick` gets ahead of in the capture phase. A build that moved
that to `mousedown`, as it already is on the Firefox path, would put xterm back in the gesture.
4. Run the terminal suite, then start the app and open a session: `RendererClipboardTests` runs the page's
own logic against a stubbed terminal, so what is left for a person is everything about the real one —
`docs/manual-checks.md` 1.11 is that list.
+121
View File
@@ -140,6 +140,54 @@ public static class PosixMode
/// <summary>Whether the others triple carries the write bit.</summary>
public static bool IsWorldWritable(string mode) => At(mode, OthersWrite) == 'w';
/// <summary>
/// Reads the nine permission bits back out of a formatted mode, as an octal number.
/// </summary>
/// <remarks>
/// The inverse of <see cref="Format"/>, and it exists because the editor that changes a mode has to
/// start from the one that is there. Anything that is not a mode this type wrote answers zero — the same
/// forgiving reading <see cref="At"/> already takes, and for the same reason: this feeds a form, and a
/// listing is not worth failing over one unrecognised string.
/// </remarks>
public static int ToOctal(string mode)
{
if (mode is not { Length: 10 })
{
return 0;
}
var bits = 0;
for (var index = 1; index < 10; index++)
{
bits <<= 1;
if (mode[index] != '-')
{
bits |= 1;
}
}
return bits;
}
/// <summary>Formats nine permission bits the way <c>chmod</c> takes them, e.g. <c>0755</c>.</summary>
/// <remarks>
/// Four digits with a leading zero, which is how every <c>chmod</c> example on earth writes one — and
/// the leading zero is the part that says out loud that the three bits this client does not write are
/// being sent as clear. See the type's own remark.
/// </remarks>
public static string FormatOctal(int mode) =>
string.Create(CultureInfo.InvariantCulture, $"0{(mode >> 6) & 7}{(mode >> 3) & 7}{mode & 7}");
/// <summary>The nine bits, and nothing above them.</summary>
/// <remarks>
/// Applied to everything on its way to a server. It is what keeps a caller from setting setuid through
/// an interface that does not admit to offering it — see the type's own remark on why those three bits
/// are neither rendered nor written.
/// </remarks>
public static int Sanitise(int mode) => mode & 0b111_111_111;
private const int OwnerExecute = 3;
private const int GroupExecute = 6;
private const int OthersWrite = 8;
@@ -393,6 +441,43 @@ public interface IRemoteFileStore : IAsyncDisposable
/// <summary>Whether the transport is still up.</summary>
bool IsConnected { get; }
/// <summary>
/// Why the connection under this store went away, or null if it did not.
/// </summary>
/// <remarks>
/// <para>
/// The same fact, latched the same way and for the same reason, as
/// <see cref="ISshShellSession.TransportFailure"/> — read that member for the argument. What differs is
/// what it is read <em>for</em>: a shell has a blocked read this releases, and a file browser has no
/// loop at all. Here it is the sentence a screen prints beside the word DISCONNECTED, and the thing that
/// tells a failed listing about a path apart from a failed listing about a connection.
/// </para>
/// <para>
/// Null forever on a store with no connection to lose. A bucket is HTTP, request per operation, and has
/// nothing that can be up between two of them.
/// </para>
/// </remarks>
string? TransportFailure { get; }
/// <summary>
/// Raised once, when the connection goes away without this client having closed it.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Because otherwise nobody finds out until they click.</b> A file browser is idle between
/// operations, so the alternative to an event is a screen that goes on saying CONNECTED over a host that
/// is gone until somebody happens to ask it for a listing — which is the same dishonesty a terminal
/// showed and the same one this is here to remove. <see cref="TransportFailure"/> is already set by the
/// time this is raised.
/// </para>
/// <para>
/// Raised on whichever thread the failure was noticed on, which is SSH.NET's message-listener thread; a
/// handler that touches a view model has to marshal. Never raised by a store with no connection to lose,
/// and never raised for a session this client disposed itself.
/// </para>
/// </remarks>
event EventHandler? ConnectionLost;
/// <summary>
/// Where the session starts, which is the account's home directory.
/// </summary>
@@ -453,6 +538,42 @@ public interface IRemoteFileStore : IAsyncDisposable
/// <summary>Renames or moves a path within the same host.</summary>
Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken);
/// <summary>
/// Sets a path's POSIX mode.
/// </summary>
/// <param name="path">What to change.</param>
/// <param name="mode">
/// The nine permission bits, as an octal number reads them — <c>0o755</c> is <c>rwxr-xr-x</c>. Only the
/// low nine bits are sent; see <see cref="PosixMode"/> for why setuid, setgid and sticky are not this
/// client's to write.
/// </param>
/// <param name="cancellationToken">Cancellation.</param>
/// <remarks>
/// ◆ <b>The one operation on this interface that writes something the listing already shows.</b> The
/// <c>PERMS</c> column has been a read-only fact since it arrived, and the commonest thing anybody does
/// straight after using this screen is open a terminal to make the file they just uploaded executable.
/// <para>
/// Refused by a store with no such thing. An object has an ACL rather than a mode, and mapping one onto
/// the other would be inventing a fact about a bucket.
/// </para>
/// </remarks>
/// <exception cref="SftpPathException">The path is not there, is not writable, or has no mode to set.</exception>
Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken);
/// <summary>
/// Sets a path's last-write time.
/// </summary>
/// <remarks>
/// What makes an upload land carrying the timestamp its source had, which is the half of "preserve" that
/// every platform can answer — a mode is not a fact about a file on Windows and a modification time is.
/// Refused by a store that has no way to say when an object was written other than when it was put.
/// </remarks>
/// <exception cref="SftpPathException">The path is not there, or its time cannot be set.</exception>
Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken);
}
/// <summary>
+64
View File
@@ -66,12 +66,76 @@ public sealed record SshConnectionRequest(
TimeSpan? ConnectTimeout = null,
SshLoopbackProxy? Proxy = null);
/// <summary>
/// How a shell session came to an end.
/// </summary>
/// <remarks>
/// <para>
/// Three, because they are the three a user would answer differently. A shell that exited is over and there
/// is nothing wrong; a connection that was lost is a machine that may well still be there, and is the one
/// worth offering to dial again; a session this client ended is a tab being closed, which nobody needs told.
/// </para>
/// <para>
/// ◆ <b>Deliberately not a fourth for "the remote rebooted".</b> Nothing on the wire distinguishes a reboot
/// from a cable being pulled — both are a transport that stops answering — and a name this client cannot
/// check would be a guess dressed as a fact. <see cref="ConnectionLost"/> is what is actually known.
/// </para>
/// </remarks>
public enum SshSessionEnd
{
/// <summary>
/// The remote ended it in an orderly way: the shell exited, or the server hung up cleanly.
/// </summary>
/// <remarks>
/// What <c>exit</c> and <c>logout</c> produce, and what a login shell being killed produces. The channel
/// closed while the transport under it was still healthy.
/// </remarks>
RemoteClosed = 0,
/// <summary>
/// The transport failed underneath it, with no channel close first.
/// </summary>
/// <remarks>
/// A reboot, a network that went away, an <c>sshd</c> that was killed, a laptop lid. See
/// <see cref="ISshShellSession.TransportFailure"/> for how this is told apart from
/// <see cref="RemoteClosed"/> without a race.
/// </remarks>
ConnectionLost = 1,
/// <summary>This client ended it: a tab was closed, a reconnect began, or the process is shutting down.</summary>
ClosedLocally = 2,
}
/// <summary>An interactive shell over a pseudo-terminal.</summary>
public interface ISshShellSession : IAsyncDisposable
{
/// <summary>Whether the channel is still usable.</summary>
bool IsOpen { get; }
/// <summary>
/// Why the transport under this shell went away, or null if it did not.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A latched value rather than a question asked afterwards, and that is the whole point of it.</b>
/// The obvious way to tell <c>exit</c> from a dropped connection is to wait for the read to report the end
/// of the shell and then ask whether the transport is still up — and it is wrong about half the time,
/// because a server whose only channel just closed hangs the connection up a millisecond later. The
/// answer to that question races the teardown; this does not.
/// </para>
/// <para>
/// What makes it safe is an ordering SSH.NET guarantees: a transport failure is raised on its
/// message-listener thread <em>before</em> that thread goes on to disconnect the session, and it is the
/// disconnect that wakes a blocked read. So a read that has just returned zero can consult this and get a
/// settled answer — set if the transport failed, still null if the remote merely closed the channel.
/// </para>
/// <para>
/// The message rather than the exception: this is read to be shown to somebody, and nothing above this
/// interface branches on a type.
/// </para>
/// </remarks>
string? TransportFailure { get; }
/// <summary>Reads whatever output is available, blocking until at least one byte arrives.</summary>
/// <returns>Bytes read, or 0 once the remote closes the channel.</returns>
ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
@@ -18,6 +18,34 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
{
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
/// <summary>
/// How often to put a byte on the wire for a connection that is otherwise idle.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Without this a rebooted machine is never noticed, and the tab claims to be connected for ever.</b>
/// An idle SSH session is a socket nobody is writing to, and a TCP connection that is not written to has
/// no way to discover that the far end has gone: the read simply never returns. That is the whole of the
/// "reboot leaves it hanging" failure — nothing was broken, nothing had been asked, so nothing could
/// fail. This is what makes the question get asked.
/// </para>
/// <para>
/// <b>It is the write that matters, not the reply.</b> SSH.NET's keep-alive is <c>SSH_MSG_IGNORE</c>,
/// which a server is not obliged to answer, so this is not a heartbeat with a deadline — it is traffic.
/// A machine that has rebooted answers the first probe with a RST, and the session is torn down at once;
/// a network that has vanished is discovered when the OS gives up retransmitting. Neither of those is a
/// timeout this client sets, which is why there is no "no reply within N seconds" rule here to be wrong
/// about a slow link.
/// </para>
/// <para>
/// Thirty seconds, which is what OpenSSH's own <c>ServerAliveInterval</c> examples use. It costs a packet
/// a minute per idle session in each direction and it bounds how long a dead session can go on looking
/// alive; shorter would not make the RST arrive sooner, because what is being waited for is the first
/// probe after the machine came back.
/// </para>
/// </remarks>
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(30);
/// <summary>
/// How much of a file SSH.NET reads or writes per SFTP request.
/// </summary>
@@ -47,7 +75,7 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
{
ArgumentNullException.ThrowIfNull(request);
var client = new SshClient(BuildConnectionInfo(request));
var client = new SshClient(BuildConnectionInfo(request)) { KeepAliveInterval = KeepAliveInterval };
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
.ConfigureAwait(false);
@@ -67,7 +95,14 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
{
ArgumentNullException.ThrowIfNull(request);
var client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize };
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
// since rebooted would otherwise wait on a socket nothing will ever answer.
var client = new SftpClient(BuildConnectionInfo(request))
{
BufferSize = SftpBufferSize,
KeepAliveInterval = KeepAliveInterval,
};
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
// reports itself, and a second connection opened behind an already-open shell has nothing the user
@@ -327,7 +362,7 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
effective.PixelHeight,
4096);
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell));
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client));
}
/// <inheritdoc />
@@ -339,10 +374,96 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
}
/// <summary>An SSH.NET-backed shell session.</summary>
internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession
internal sealed class SshNetShellSession : ISshShellSession
{
private readonly ShellStream shell;
private readonly SshClient client;
private string? transportFailure;
/// <param name="shell">The channel this shell reads and writes.</param>
/// <param name="client">
/// The connection underneath it, subscribed to for the one thing the stream cannot report on its own —
/// see <see cref="TransportFailure"/>.
/// </param>
internal SshNetShellSession(ShellStream shell, SshClient client)
{
this.shell = shell;
this.client = client;
client.ErrorOccurred += OnTransportError;
}
/// <inheritdoc />
public bool IsOpen => shell.CanRead;
/// <remarks>
/// <c>CanWrite</c> rather than <c>CanRead</c>, which is what this used to read and which is a constant:
/// <c>ShellStream.CanRead</c> is documented as always true — it stays readable after disposal so that
/// buffered output can still be drained — so this property answered "yes" for every shell that had ever
/// existed. <c>CanWrite</c> is the one that goes false when the stream is disposed, which is what closing
/// the channel and losing the session both do.
/// </remarks>
public bool IsOpen => shell.CanWrite;
/// <inheritdoc />
/// <remarks>
/// Written on SSH.NET's message-listener thread and read on the pump's, which is why it is a
/// <see cref="Volatile"/> pair rather than a plain field: the two threads are ordered by the wake-up the
/// interface's own remark describes, but nothing in the language says so, and a torn or cached read here
/// would report a clean exit for a connection that had just died.
/// </remarks>
public string? TransportFailure => Volatile.Read(ref transportFailure);
/// <summary>
/// Latches the first transport failure and ends the read that is waiting on a connection which is
/// no longer there.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>THE SECOND HALF OF THIS IS WHY A REBOOTED MACHINE USED TO HANG FOR EVER, AND IT IS A DEFECT IN
/// SSH.NET RATHER THAN IN ANYTHING ABOVE IT.</b> <c>ShellStream</c> ends a blocked read from exactly two
/// places: its channel being closed, and the session raising <c>Disconnected</c>. A shell that exits
/// gives the first. But <c>Session</c> raises <c>Disconnected</c> from one method only —
/// <c>OnDisconnectReceived</c>, for a server that politely says goodbye — and a socket that simply fails
/// goes down a different path: it raises the error event, sets its disconnecting flag and disposes the
/// socket, without ever raising <c>Disconnected</c>. So the stream was never disposed, its
/// <c>Monitor.Wait</c> was never pulsed, and the read blocked until the process ended. The transport was
/// gone, the client knew the transport was gone, and the terminal went on looking connected.
/// </para>
/// <para>
/// Keep-alive is what makes the failure <em>happen</em> on an idle connection; this is what makes it
/// <em>arrive</em>. Neither is sufficient alone, which is why both are in this file.
/// </para>
/// <para>
/// <b>Queued rather than disposed inline, and that is not tidiness.</b> This runs on SSH.NET's own
/// message-listener thread. Closing a channel waits for the server's close to come back — which is
/// processed by that same thread — so disposing here would be the listener thread waiting on itself, and
/// the best case is that it unwinds when the wait times out.
/// </para>
/// <para>
/// The latch is set before the queue, so <see cref="TransportFailure"/> is already true by the time the
/// read it releases can look at it. That ordering is the whole of the promise this property makes.
/// </para>
/// </remarks>
private void OnTransportError(object? sender, ExceptionEventArgs e)
{
Interlocked.CompareExchange(ref transportFailure, e.Exception.Message, comparand: null);
ThreadPool.UnsafeQueueUserWorkItem(
static state =>
{
try
{
state.Dispose();
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Best-effort. The connection is already gone; what matters is that the read was
// released, and a fault tearing down a dead channel has no caller to report it to.
}
},
shell,
preferLocal: false);
}
/// <inheritdoc />
/// <remarks>
@@ -388,6 +509,10 @@ internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
// Unsubscribed before the stream goes, so a connection that outlives this shell — the workspace
// disposes the two in that order — is not still holding a reference to it through its own event.
client.ErrorOccurred -= OnTransportError;
await shell.DisposeAsync().ConfigureAwait(false);
}
}
+103 -5
View File
@@ -11,14 +11,35 @@ namespace DodoSSH.Client.Ssh;
/// this type does beyond forwarding calls is translate SSH.NET's several path failures into the single one a
/// caller can act on.
/// </remarks>
internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
: ISftpSession
internal sealed class SshNetSftpSession : ISftpSession
{
private readonly SftpClient client;
private string? transportFailure;
private int announced;
internal SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
{
this.client = client;
HostKey = hostKey;
HomeDirectory = homeDirectory;
Cipher = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
client.ErrorOccurred += OnTransportError;
}
/// <inheritdoc />
public bool IsConnected => client.IsConnected;
/// <inheritdoc />
public HostKeyPresentation HostKey { get; } = hostKey;
public string? TransportFailure => Volatile.Read(ref transportFailure);
/// <inheritdoc />
public event EventHandler? ConnectionLost;
/// <inheritdoc />
public HostKeyPresentation HostKey { get; }
/// <inheritdoc />
/// <remarks>
@@ -27,10 +48,36 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from. The coalesce
/// is there for SSH.NET's nullable annotation, which covers the pre-key-exchange window this never sees.
/// </remarks>
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
public string Cipher { get; }
/// <inheritdoc />
public string HomeDirectory { get; } = homeDirectory;
public string HomeDirectory { get; }
/// <summary>
/// Latches the failure and says so once.
/// </summary>
/// <remarks>
/// <para>
/// No blocked read to release, unlike the shell's own handler: every SFTP call is a request and a reply,
/// so a connection that has gone is discovered by the next one throwing. What this adds is that somebody
/// finds out <em>without</em> making that next call — a file browser sits idle between operations, and
/// waiting for a click to reveal a host that went away half an hour ago is exactly the stale CONNECTED
/// this exists to remove.
/// </para>
/// <para>
/// Announced once. SSH.NET can raise several errors as one connection comes apart, and a screen told
/// three times would report the second and third against a session it had already replaced.
/// </para>
/// </remarks>
private void OnTransportError(object? sender, ExceptionEventArgs e)
{
Interlocked.CompareExchange(ref transportFailure, e.Exception.Message, comparand: null);
if (Interlocked.Exchange(ref announced, 1) == 0)
{
ConnectionLost?.Invoke(this, EventArgs.Empty);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
@@ -141,10 +188,61 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
}
}
/// <inheritdoc />
/// <remarks>
/// ◆ <b>On a thread-pool thread, because SSH.NET offers no asynchronous <c>SETSTAT</c>.</b>
/// <c>ChangePermissions</c> is a blocking round trip and every caller here is on the thread the
/// interface is drawn from — so calling it directly would freeze the window for a link's latency, which
/// on the connections this product exists for is the difference between instant and noticeably not.
/// Every other operation on this type has an async form and uses it.
/// </remarks>
public async Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
{
// Sanitised here rather than trusted from the caller, so that the promise the interface makes about
// the three bits it does not write holds at the last point before the wire.
var bits = (short)PosixMode.Sanitise(mode);
try
{
await Task.Run(() => client.ChangePermissions(path, bits), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (IsPathFailure(exception))
{
throw Translate(path, exception);
}
}
/// <inheritdoc />
/// <remarks>See <see cref="SetPermissionsAsync"/>; the same blocking round trip, moved off the caller's thread for the same reason.</remarks>
public async Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken)
{
var when = lastWriteTimeUtc.UtcDateTime;
try
{
await Task.Run(() => client.SetLastWriteTimeUtc(path, when), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (IsPathFailure(exception))
{
throw Translate(path, exception);
}
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
// Before the client goes, and it also settles the flag: a disposal that trips SSH.NET's own error
// path must not reach a screen as a connection this client did not mean to lose.
Interlocked.Exchange(ref announced, 1);
client.ErrorOccurred -= OnTransportError;
client.Dispose();
return ValueTask.CompletedTask;
}
+281
View File
@@ -0,0 +1,281 @@
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
namespace DodoSSH.Client.Ssh;
/// <summary>
/// What one look at an endpoint found, once a connection to it had already failed.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Four, because they are the four a user would do something different about.</b> A name that does not
/// resolve is a typo or a VPN that is down; a port that refuses is a machine that is up with no <c>sshd</c>
/// on it; a port that says nothing at all is a firewall or a machine that is off; and a port that opens is
/// the one that matters most, because it means the network is fine and the SSH handshake is what failed.
/// </para>
/// <para>
/// <b>Nothing here is inferred from the exception that started it.</b> Each value is what a socket actually
/// did, measured at the moment it was asked, which is what makes it safe for a screen to state as fact —
/// the same rule <see cref="SshConnectionPhase"/> is written to.
/// </para>
/// </remarks>
public enum SshReach
{
/// <summary>The name does not resolve on this machine, so there was no address to dial.</summary>
Unresolved = 0,
/// <summary>It resolved, and the port sent nothing back before the probe gave up.</summary>
NoAnswer = 1,
/// <summary>It resolved, and the host said at once that nothing is listening there.</summary>
Refused = 2,
/// <summary>It resolved, and something accepted a plain TCP connection on that port.</summary>
Open = 3,
}
/// <summary>What a probe of one endpoint found, and the sentence that says so.</summary>
/// <param name="Reach">Which of the four it was.</param>
/// <param name="Host">The name or address that was probed, as the connection named it.</param>
/// <param name="Port">The port that was probed.</param>
/// <param name="Address">
/// The address the name resolved to and the probe actually dialled, or null when it did not resolve.
/// <para>
/// One rather than all of them. A name with several addresses is dialled at whichever the stack picks
/// first, and naming the one that was tried is a fact; listing the rest would suggest they were.
/// </para>
/// </param>
/// <param name="Elapsed">How long the probe took, which is the evidence behind <see cref="SshReach.NoAnswer"/>.</param>
/// <param name="Summary">The finding in plain words, ready to put in front of somebody.</param>
public sealed record SshReachabilityReport(
SshReach Reach,
string Host,
int Port,
string? Address,
TimeSpan Elapsed,
string Summary);
/// <summary>Looks at an endpoint that would not connect, and reports what is actually wrong with it.</summary>
/// <remarks>
/// Separate from <see cref="ISshConnectionFactory"/> rather than a method on it, because it is not a way of
/// connecting: it runs after one has failed, it authenticates nothing, and it must stay usable from a caller
/// that has no credential to hand — which the failure path is, by the time it needs this.
/// </remarks>
public interface ISshReachabilityProbe
{
/// <summary>
/// Resolves the name and opens a bare TCP connection to the port, to find out which part is broken.
/// </summary>
/// <param name="host">The hostname or address the failed connection named.</param>
/// <param name="port">The port it named.</param>
/// <param name="cancellationToken">Abandons the probe.</param>
/// <remarks>
/// Never throws for a host that is simply broken — being unreachable is the answer, not an error. A
/// caller is already in a failure path and has nothing useful to do with a second exception.
/// </remarks>
ValueTask<SshReachabilityReport> ProbeAsync(string host, int port, CancellationToken cancellationToken);
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// ◆ <b>Why this exists at all: two unrelated failures arrive as the same sentence.</b> SSH.NET reports both
/// "the machine never answered" and "the machine answered TCP and then never spoke SSH" as
/// <c>SshOperationTimeoutException</c> with the message "Connection has timed out." Those are not the same
/// problem and do not have the same fix — one is a network or a firewall, the other is an <c>sshd</c> that
/// is wedged or a port that is not SSH at all — and nothing in the exception tells them apart. A socket does,
/// in about as long as it takes to open one.
/// </para>
/// <para>
/// <b>A plain TCP connect and a name lookup, and deliberately nothing else.</b> No ping, because ICMP is
/// filtered on most of the networks this product is pointed at and an unanswered ping 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 the kind of thing that gets a client flagged by an intrusion
/// detector on the very network somebody is trying to work on. No SSH banner read either — that is a second
/// handshake against a server that has already failed one, and the open socket has answered the question.
/// </para>
/// <para>
/// <b>The whole probe is bounded, and the bound is short.</b> By the time this runs the user has already
/// waited out a connect timeout, so the budget here is a few seconds spent buying an answer rather than
/// another wait to sit through. Exceeding it is itself a finding — nothing came back — so the deadline
/// produces a result rather than an error.
/// </para>
/// </remarks>
public sealed class SshReachabilityProbe : ISshReachabilityProbe
{
/// <summary>
/// How long the name lookup and the connection attempt get between them.
/// </summary>
/// <remarks>
/// <para>
/// A budget rather than two timeouts, so a slow resolver cannot spend the socket's share and leave the
/// interesting half of the probe unrun. It is well under the fifteen this client allows a real connection
/// — see <c>SshNetConnectionFactory</c> — because a machine that answers at all answers a bare TCP
/// connect in a fraction of what an SSH handshake takes, and everything past that point is waiting to
/// confirm silence somebody has already waited out once.
/// </para>
/// <para>
/// ◆ <b>Six rather than two, and the floor under it is not a guess.</b> A refusal is not instant on
/// Windows: a closed port on loopback took ~2s to come back as <c>WSAECONNREFUSED</c> when this was
/// measured, because the stack retries the SYN before reporting. Anything at or under that turns
/// <see cref="SshReach.Refused"/> — a machine that is up — into <see cref="SshReach.NoAnswer"/>, which
/// this client would then explain as a firewall. The budget must clear the platform's SYN-retry time
/// with room to spare, and a couple of seconds of headroom is what the extra four buy. Shortening this
/// without re-measuring that floor reintroduces a wrong answer rather than a slow one.
/// </para>
/// </remarks>
public static readonly TimeSpan Budget = TimeSpan.FromSeconds(6);
private readonly TimeSpan budget;
/// <summary>Uses the standard <see cref="Budget"/>.</summary>
public SshReachabilityProbe()
: this(Budget)
{
}
/// <param name="budget">
/// How long the whole probe gets. A test uses this to keep a deliberately silent endpoint from costing
/// the suite six seconds; nothing in the application passes one.
/// </param>
public SshReachabilityProbe(TimeSpan budget) => this.budget = budget;
/// <inheritdoc />
public async ValueTask<SshReachabilityReport> ProbeAsync(
string host,
int port,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
var clock = Stopwatch.StartNew();
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(budget);
var address = await ResolveAsync(host, deadline.Token).ConfigureAwait(false);
if (address is null)
{
return Report(SshReach.Unresolved, host, port, address: null, clock.Elapsed);
}
var reach = await ConnectAsync(address, port, deadline.Token).ConfigureAwait(false);
return Report(reach, host, port, address.ToString(), clock.Elapsed);
}
/// <summary>
/// The address the connection would have been made to, or null if the name does not resolve.
/// </summary>
/// <remarks>
/// An address typed in rather than a name costs no lookup: <see cref="IPAddress.TryParse(string, out IPAddress)"/>
/// settles it here, where <see cref="Dns"/> would go and ask the resolver about a string it is going to
/// hand straight back. That matters for the report as much as for the speed — a literal that "resolved"
/// would be a fact this probe invented about a machine it never asked anybody about.
/// </remarks>
private static async ValueTask<IPAddress?> ResolveAsync(string host, CancellationToken cancellationToken)
{
if (IPAddress.TryParse(host, out var literal))
{
return literal;
}
try
{
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
// First rather than a preference between v4 and v6: this is reporting on the connection that
// failed, and the stack picked from this same list in this same order when it made it.
return addresses.Length == 0 ? null : addresses[0];
}
catch (Exception exception) when (exception is SocketException or OperationCanceledException)
{
// A resolver that refuses and a resolver that never answers are the same finding here: there is
// no address, so there was nothing to dial. Which of the two it was is a DNS question, and this
// probe would be guessing at the answer.
return null;
}
}
/// <remarks>
/// The socket is opened and dropped without a byte being sent. A TCP handshake is the whole question —
/// whether anything is listening — and writing to a server that is about to be told nothing is wanted
/// would leave a connection attempt in its log that the user did not make.
/// </remarks>
private static async ValueTask<SshReach> ConnectAsync(
IPAddress address,
int port,
CancellationToken cancellationToken)
{
using var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
return SshReach.Open;
}
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionRefused)
{
return SshReach.Refused;
}
catch (Exception exception) when (exception is SocketException or OperationCanceledException)
{
// Everything else the stack can say here — unreachable, timed out, the deadline above — comes to
// the same account of it: the port never answered. Naming the OS error instead would be trading a
// sentence somebody can act on for one they would have to look up.
return SshReach.NoAnswer;
}
}
private static SshReachabilityReport Report(
SshReach reach,
string host,
int port,
string? address,
TimeSpan elapsed)
=> new(reach, host, port, address, elapsed, Describe(reach, host, port, address, elapsed));
/// <summary>
/// The finding as a sentence, saying what was measured and then what it means.
/// </summary>
/// <remarks>
/// Measurement first in every case, on the rule the rest of this product's failure text follows: the
/// fact is what the user can check and act on, and the interpretation after it is this client's reading
/// of that fact. Each reading is hedged exactly as far as it deserves — a refusal has one common cause
/// and names it, silence has three and lists them, and an open port names the class of problem rather
/// than pretending to know which member of it this is.
/// </remarks>
private static string Describe(SshReach reach, string host, int port, string? address, TimeSpan elapsed)
{
var seconds = elapsed.TotalSeconds.ToString("0.#", CultureInfo.CurrentCulture);
var where = address is null || string.Equals(address, host, StringComparison.OrdinalIgnoreCase)
? host
: $"{host} ({address})";
return reach switch
{
SshReach.Unresolved =>
$"The name {host} does not resolve on this machine, so nothing was ever dialled. Check the "
+ "spelling, or whether the VPN or DNS server this name depends on is up.",
SshReach.Refused =>
$"{where} is reachable and refused port {port} immediately: the machine is up and nothing is "
+ "listening there. Either sshd is not running, or this is the wrong port.",
SshReach.NoAnswer =>
$"{where} sent nothing back on port {port} within {seconds}s. A machine that was up and had "
+ "nothing listening would have refused at once, so this is a firewall dropping the "
+ "packets, a machine that is off, or the wrong address.",
_ =>
$"Port {port} on {where} accepts connections, so the network and the machine are fine and "
+ "something is listening. The SSH handshake is what did not finish: an sshd that is "
+ "overloaded or wedged, a middlebox holding the connection open, or a service on this "
+ "port that is not SSH.",
};
}
}
+18 -12
View File
@@ -1,27 +1,33 @@
namespace DodoSSH.Client.Terminal;
/// <summary>
/// Credit-based flow control over one terminal session's output.
/// Credit-based flow control over what has been sent to the renderer and not yet drawn.
/// </summary>
/// <remarks>
/// <para>
/// This is what makes <c>yes</c> survivable. A terminal emulator renders at 60 Hz at best, while a
/// remote process can produce output as fast as the network allows. Without a limit the difference
/// accumulates somewhere — an unbounded queue in the client, or an ever-growing scrollback — and the
/// application's memory grows until it dies.
/// A terminal emulator renders at 60 Hz at best, while a remote process can produce output as fast as
/// the network allows. The difference has to accumulate somewhere, and the one place it must never
/// accumulate is inside the WebView: xterm queues whatever it is written and nothing on this side can
/// see how deep that queue is, so a page told to draw faster than it can grows until the tab dies.
/// </para>
/// <para>
/// The mechanism: the renderer is granted a window of bytes it is allowed to be behind by. Each byte
/// sent to it consumes credit; each byte it reports having actually rendered returns credit. When
/// credit reaches zero the pump <b>stops reading the SSH channel</b>. That closes SSH's own receive
/// window, which makes the remote <c>sshd</c> block on write, which propagates the backpressure all
/// the way to the process producing the output. Nothing buffers without bound because nothing is read
/// that cannot be delivered.
/// credit reaches zero the pump sends nothing more until some comes back.
/// </para>
/// <para>
/// The window has to be large enough that a normal burst never stalls and small enough to bound
/// memory. 256 KiB is roughly a screenful of dense output many times over, and it caps a session's
/// in-flight cost at a quarter of a megabyte.
/// ◆ <b>What this does not do is bound what the remote has sent</b>, and it used to be written as
/// though it did — the claim was that a pump which stops reading closes SSH's own receive window and
/// blocks the remote <c>sshd</c>. It does not, because SSH.NET re-opens that window as bytes arrive
/// rather than as they are consumed. Holding output back therefore never reached the remote at all;
/// it only chose which buffer the flood landed in, and the one it landed in had no ceiling. The bound
/// that was missing now lives in <see cref="OutputBacklog"/>, which is also where the evidence for
/// this paragraph is written down.
/// </para>
/// <para>
/// The window has to be large enough that a normal burst never stalls and small enough to bound what
/// the page is holding. 256 KiB is roughly a screenful of dense output many times over, and it caps a
/// session's in-flight cost at a quarter of a megabyte.
/// </para>
/// </remarks>
public sealed class CreditWindow
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more