Compare commits

...
Author SHA1 Message Date
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
80 changed files with 9497 additions and 449 deletions

No files matched your search

+33 -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
@@ -556,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.
@@ -915,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
@@ -926,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.
@@ -1226,7 +1250,10 @@ 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
@@ -1279,7 +1306,8 @@ jobs:
# upload it never made.
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
for name in $linuxNames; do
if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$name\""; then
# 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
@@ -1290,6 +1318,8 @@ jobs:
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
+2 -1
View File
@@ -69,7 +69,8 @@ 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, macOS and Linux integration
DodoSSH.Client.Android the phone head: its views, and its Android integration
+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": [
+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.
+375 -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,88 @@ 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.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 +447,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 +1842,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 +2016,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 +2039,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 +2188,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 +2851,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 +2922,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
+24 -5
View File
@@ -223,6 +223,25 @@ echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
#
# 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")"
@@ -236,7 +255,7 @@ files="$(rpm -qp --list "$rpm_file")"
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 -qF "$soname("; then
if printf '%s\n' "$requires" | grep -F "$soname(" >/dev/null; then
unsatisfiable="$unsatisfiable $soname"
fi
done
@@ -251,7 +270,7 @@ 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 -qE '^lib.*\.so'; then
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
@@ -262,7 +281,7 @@ fi
# 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 -q '^libc\.so\.6'; then
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
@@ -279,9 +298,9 @@ for expected in \
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
do
if ! printf '%s\n' "$files" | grep -qxF "$expected"; then
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" | head -40 >&2
printf '%s\n' "$files" | awk 'NR<=40' >&2
exit 1
fi
done
+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.
@@ -473,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}" />
@@ -551,7 +551,7 @@
Foreground="{StaticResource Accent}"
Background="{StaticResource Raised}" />
<TextBlock Classes="detail" Text="{Binding Progress}"
TextTrimming="CharacterEllipsis" />
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
@@ -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();
}
}
@@ -341,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}" />
@@ -471,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 ============ -->
@@ -482,13 +505,50 @@
-->
<!-- 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.
-->
<!-- ============ what a terminal says once its shell has stopped ============ -->
<!--
@@ -539,62 +599,70 @@
</StackPanel>
</Border>
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
<Grid ColumnDefinitions="*,Auto">
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
VerticalAlignment="Center" />
</ScrollViewer>
<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
+119 -3
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
@@ -126,12 +203,44 @@ internal sealed partial class DodoSshApp : Application
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);
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>
@@ -169,6 +278,7 @@ internal sealed partial class DodoSshApp : Application
.ConfigureAwait(false),
copyToClipboard: ClipboardWriter(desktop),
readClipboard: ClipboardReader(desktop),
updates: updates,
openInEditor: OpenInDefaultEditorAsync,
watchFile: WatchFile);
@@ -259,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
IClassicDesktopStyleApplicationLifetime desktop,
MainWindowViewModel viewModel,
TerminalWorkspace workspace,
ClientCacheFactory caches)
ClientCacheFactory caches,
IAsyncDisposable others)
{
var shuttingDown = false;
@@ -278,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();
@@ -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.
}
}
}
}
@@ -204,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 />
@@ -321,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 />
@@ -335,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);
}
+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>
<!--
@@ -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();
+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"/>.
@@ -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.");
}
}
@@ -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);
+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;
@@ -493,6 +525,10 @@ 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
@@ -527,12 +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,
Func<string, Task>? openInEditor = null,
Func<string, Action, IDisposable>? watchFile = null)
Func<string, Action, IDisposable>? watchFile = null,
ISshReachabilityProbe? reachability = null)
{
this.reachability = reachability;
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
this.paths = paths;
@@ -545,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
@@ -575,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);
@@ -726,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
@@ -913,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;
@@ -1414,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;
@@ -1476,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)
{
@@ -1494,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>
@@ -1518,6 +1700,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ActiveSettingsPage = page;
IsImportOpen = false;
IsHetznerImportOpen = false;
Screen = page switch
{
@@ -1539,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]
@@ -1554,6 +1760,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ActiveSettingsPage = null;
IsImportOpen = false;
IsHetznerImportOpen = false;
Screen = settingsReturnScreen;
Surface = settingsReturnSurface;
}
@@ -2678,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
@@ -3203,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;
@@ -3434,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
@@ -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,
@@ -1391,8 +1445,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 +5234,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 +5347,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 +5360,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 +5387,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 +5403,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 +5431,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 +5463,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 +5484,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 +5494,7 @@ internal sealed partial class VaultViewModel(
expanded)
{
VaultBadge = group?.VaultBadge ?? string.Empty,
CollapseKey = key,
});
if (!expanded)
@@ -5458,7 +5640,7 @@ internal sealed partial class VaultViewModel(
return;
}
var key = header.GroupId ?? Guid.Empty;
var key = header.CollapseKey;
if (!collapsedGroups.Remove(key))
{
@@ -5490,7 +5672,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 +6441,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 +6533,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 +8536,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 +8581,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 +8608,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 +8620,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 +8650,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 +8677,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 +8752,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 +11243,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>
@@ -11240,9 +11561,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 +11593,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 +11739,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 +11753,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
@@ -33,11 +33,14 @@ const SERVER_SESSION_ACTIVATED = 4;
const SERVER_SESSION_REMOVED = 5;
const SERVER_PASTE = 6;
const SERVER_FONT_SIZE = 7;
const SERVER_CLIPBOARD = 8;
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 +282,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 +365,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 +422,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));
});
@@ -562,6 +642,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 +917,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.
+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.",
};
}
}
@@ -105,6 +105,23 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>Raised when the page has text for the system clipboard.</summary>
/// <remarks>
/// A right-click over a selection. Raised on the socket's receive loop, like
/// <see cref="FontSizeStepRequested"/> and with the same obligation on a handler that touches a view
/// model — and the clipboard itself is a UI-thread object on more than one platform, so this one has to
/// be marshalled even where nothing is bound to.
/// </remarks>
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
/// <summary>Raised when the page wants the system clipboard's text pasted into one of its panes.</summary>
/// <remarks>
/// A right-click with nothing selected. Raised on the socket's receive loop; see
/// <see cref="ClipboardCopyRequested"/>. The answer goes back as
/// <see cref="TerminalServerOpcode.Clipboard"/>, which is <see cref="TerminalWorkspace"/>'s to send.
/// </remarks>
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
/// <summary>
/// Raised after a socket attaches — the first one, and every later takeover.
/// </summary>
@@ -463,16 +480,8 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
return;
}
// Answered before the session lookup, because it is the one frame that is not about a session. The
// page sends whichever id it had to hand, and a chord pressed in a terminal whose shell has just
// ended is still a request to make the text bigger.
if ((TerminalClientOpcode)opcode is TerminalClientOpcode.FontSizeStep)
if (Announce(opcode, sessionId, payload))
{
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
{
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
}
return;
}
@@ -518,6 +527,59 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
}
}
/// <summary>
/// Raises the frames no pump needs to see, and says whether this was one of them.
/// </summary>
/// <remarks>
/// <para>
/// Answered before the session lookup, because none of these three touches a pump. A text-size chord is
/// about the person reading rather than about one terminal, and the page sends whichever id it had to
/// hand. A copy is about the machine's clipboard. A paste request is answered by sending a frame back
/// rather than by writing to a pty, and <see cref="TerminalWorkspace.SendClipboardAsync"/> does its own
/// liveness check — which is the one that can honestly refuse.
/// </para>
/// <para>
/// Which is also what keeps all three working in a pane whose shell has just ended, and that is when
/// they are most wanted: squinting at the message a remote left behind is exactly when somebody makes
/// the text bigger or copies the line. Dropping the frame at the pump lookup would have made a dead tab
/// the one place the gesture silently did nothing.
/// </para>
/// </remarks>
private bool Announce(byte opcode, uint sessionId, ReadOnlySpan<byte> payload)
{
switch ((TerminalClientOpcode)opcode)
{
case TerminalClientOpcode.FontSizeStep:
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
{
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
}
return true;
case TerminalClientOpcode.ClipboardCopy:
// An empty copy is dropped rather than forwarded. The clipboard is shared with everything
// else on the machine, so "copy nothing" would be a gesture that quietly destroys whatever
// was on it — and the page does not send one, which makes this a guard against a frame
// arriving from something other than the page we shipped.
if (payload.Length > 0)
{
ClipboardCopyRequested?.Invoke(
this,
new TerminalClipboardCopyEventArgs(Encoding.UTF8.GetString(payload)));
}
return true;
case TerminalClientOpcode.ClipboardPaste:
ClipboardPasteRequested?.Invoke(this, new TerminalClipboardPasteEventArgs(sessionId));
return true;
default:
return false;
}
}
/// <summary>
/// Computes the <c>Sec-WebSocket-Accept</c> value RFC 6455 §4.2.2 requires.
/// </summary>
@@ -95,6 +95,31 @@ public enum TerminalServerOpcode : byte
/// </para>
/// </remarks>
FontSize = 7,
/// <summary>
/// The system clipboard's text, for this session's pane to paste. Payload is UTF-8.
/// </summary>
/// <remarks>
/// <para>
/// Sent in answer to <see cref="TerminalClientOpcode.ClipboardPaste"/>, which is the only thing that
/// asks for it: a right-click over a pane with nothing selected. The host reads the clipboard because
/// the page cannot — see that opcode for why not.
/// </para>
/// <para>
/// <b>Not <see cref="Paste"/>, and the difference is the bracketing.</b> That opcode carries a snippet
/// off the sidebar, and the page deliberately <em>types</em> 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 this one is always bracketed. One opcode for both would need a flag
/// meaning "which of the two kinds of paste is this", which is the same distinction spelled less
/// clearly, on a payload that already has a flag byte meaning something else.
/// </para>
/// <para>
/// No flag byte, therefore, and no Enter: a clipboard 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.
/// </para>
/// </remarks>
Clipboard = 8,
}
/// <summary>Frames the renderer sends to the host.</summary>
@@ -131,6 +156,52 @@ public enum TerminalClientOpcode : byte
/// </para>
/// </remarks>
FontSizeStep = 4,
/// <summary>
/// Text to put on the system clipboard. Payload is UTF-8.
/// </summary>
/// <remarks>
/// <para>
/// A right-click over a pane with something selected. What gets copied is the selection, and only the
/// page knows what that is — xterm draws it into a canvas and the host never sees a glyph of it — so it
/// is the text that travels here rather than a request for it.
/// </para>
/// <para>
/// <b>Through the host rather than through the page's own <c>navigator.clipboard</c>.</b> Writing the
/// clipboard from the page would work on Windows, where the renderer is Blink and
/// <c>clipboard-write</c> is granted to a focused document without asking. It is the <em>reading</em>
/// that cannot be done that way — see <see cref="ClipboardPaste"/> — and 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; this is that one.
/// </para>
/// <para>
/// The session id is the pane the selection came from. Nothing routes on it — a clipboard is not a
/// property of one terminal — and it is carried because every frame carries one.
/// </para>
/// </remarks>
ClipboardCopy = 5,
/// <summary>
/// The page asking for the clipboard's text, to paste into this session. No payload.
/// </summary>
/// <remarks>
/// <para>
/// A right-click over a pane with nothing selected. The answer arrives as
/// <see cref="TerminalServerOpcode.Clipboard"/>, addressed to the session id this frame carried, so the
/// text lands in the pane the pointer was over rather than in whichever one the host believes is
/// selected. They agree in every arrangement that exists today, because only the active pane is
/// visible — but the page is the one that knows, so the page is the one that says.
/// </para>
/// <para>
/// <b>A request rather than the page reading the clipboard itself, and this is the half with no
/// alternative.</b> <c>navigator.clipboard.readText()</c> is gated behind the <c>clipboard-read</c>
/// permission in every Chromium, and an embedded WebView answers a permission request it has no handler
/// for by putting a prompt on the screen. Trading a context menu for a permission dialog is not the
/// change this was.
/// </para>
/// </remarks>
ClipboardPaste = 6,
}
/// <summary>
@@ -72,6 +72,28 @@ public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
public int Step { get; } = step;
}
/// <summary>The renderer handing over text for the system clipboard.</summary>
/// <param name="text">
/// The selection, exactly as the page had it. Never empty — the transport drops an empty copy rather than
/// letting a stray gesture wipe the machine's clipboard.
/// </param>
public sealed class TerminalClipboardCopyEventArgs(string text) : EventArgs
{
/// <summary>What to put on the clipboard.</summary>
public string Text { get; } = text;
}
/// <summary>The renderer asking for the system clipboard's text.</summary>
/// <param name="sessionId">
/// The pane the pointer was over, which is where the text is to land. The page sends it rather than leaving
/// the host to infer it from whichever tab it believes is selected.
/// </param>
public sealed class TerminalClipboardPasteEventArgs(uint sessionId) : EventArgs
{
/// <summary>The terminal to paste into.</summary>
public uint SessionId { get; } = sessionId;
}
/// <summary>
/// Owns the loopback data plane and every live terminal session.
/// </summary>
@@ -148,6 +170,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
// what remembers it between launches.
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
// Forwarded for the same reason and decided no more than the size is. What a clipboard is and how it
// is reached is a property of the platform the shell is running on, and this object has never seen a
// visual.
dataPlane.ClipboardCopyRequested += (_, e) => ClipboardCopyRequested?.Invoke(this, e);
dataPlane.ClipboardPasteRequested += (_, e) => ClipboardPasteRequested?.Invoke(this, e);
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
// handshake handling, and has no business making that wait on however long a replay takes. See
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
@@ -313,6 +341,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>Raised when a right-click over a selection asks for it to be copied.</summary>
/// <remarks>
/// The gesture can only be heard by the page, for the reason <see cref="FontSizeStepRequested"/> gives,
/// and the selection can only be <em>read</em> there: xterm draws it into a canvas. So the text arrives
/// here, and what a clipboard is on this platform is the shell's to know. Raised on the data plane's
/// receive loop; marshal before touching one.
/// </remarks>
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
/// <summary>Raised when a right-click with nothing selected asks for the clipboard to be pasted.</summary>
/// <remarks>
/// Answered with <see cref="SendClipboardAsync"/>, which is the other half: this event carries no text
/// because the page has none to give — reading the clipboard is exactly what it cannot do. Raised on the
/// data plane's receive loop; marshal before touching a view model, and before touching a clipboard,
/// which is a UI-thread object on more than one platform.
/// </remarks>
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
/// <summary>
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
/// </summary>
@@ -359,6 +405,55 @@ public sealed class TerminalWorkspace : IAsyncDisposable
TerminalFrame.CreateFontSizePayload(pixels)),
cancellationToken);
/// <summary>
/// Hands the system clipboard's text to one pane, to be pasted into it.
/// </summary>
/// <remarks>
/// <para>
/// The answer to <see cref="ClipboardPasteRequested"/>, and the only thing that sends this frame. Always
/// bracketed and never followed by an Enter — see <see cref="TerminalServerOpcode.Clipboard"/> for why a
/// clipboard paste is not the same act as inserting a snippet, and why the two do not share an opcode.
/// </para>
/// <para>
/// Refused for a dead session, exactly as <see cref="PasteAsync"/> is refused and for the weaker of its
/// two reasons: nothing visible would happen either way, because a paste is keystrokes rather than
/// output and there is no shell left to echo them. Checked anyway so that the answer is honest, and so
/// that a caller which grows a message one day has something to hang it on.
/// </para>
/// <para>
/// An empty clipboard is the caller's to notice. Sending nothing is not the same as sending an empty
/// string — the page would bracket it and the remote would see an empty paste — and "there was nothing
/// to paste" is a fact about the machine rather than about this terminal.
/// </para>
/// </remarks>
/// <param name="sessionId">The terminal to paste into.</param>
/// <param name="text">The clipboard's text. Sent verbatim.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns><see langword="false"/> when that session's shell is not running.</returns>
public async Task<bool> SendClipboardAsync(
uint sessionId,
string text,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(text);
if (!IsSessionLive(sessionId))
{
return false;
}
await dataPlane
.SendAsync(
TerminalFrame.Create(
(byte)TerminalServerOpcode.Clipboard,
sessionId,
Encoding.UTF8.GetBytes(text)),
cancellationToken)
.ConfigureAwait(false);
return true;
}
/// <summary>
/// Waits until the renderer page has attached its socket.
/// </summary>
@@ -250,12 +250,19 @@ public sealed class HostGridTests : IAsyncLifetime
/// way down rather than acted on as it bubbles.
/// </para>
/// <para>
/// ◆ <b>And the set the first Ctrl-click starts has the lit card in it.</b> 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. A Ctrl-click there used to produce a set of
/// one, leaving the machine the user was looking at out of the count and out of everything Delete, Move
/// and Change group then acted on. See <c>VaultViewModel.ExtendHostChoice</c>.
/// </para>
/// <para>
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
/// card is in play and every command on this screen is about the same host.
/// </para>
/// </remarks>
[Fact]
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
public async Task CtrlClickingCardsTicksThemAndTheLitCardWithoutMovingTheSelection()
{
await OnTheBoardAsync((screen, window) =>
{
@@ -267,16 +274,19 @@ public sealed class HostGridTests : IAsyncLifetime
Click(CardFor(screen, second), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(1);
vault.ChosenHostCount.ShouldBe(2, "the card the board had lit is in the set the Ctrl-click began");
first.IsChosen.ShouldBeTrue("which is the half that used to be left out of it");
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise");
Click(CardFor(screen, first), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(2);
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
first.IsChosen.ShouldBeFalse("the lit card is not folded back in over a set that already exists");
Click(CardFor(screen, first), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
vault.ChosenHostCount.ShouldBe(2);
Click(CardFor(screen, first), window);
@@ -387,6 +397,12 @@ public sealed class HostGridTests : IAsyncLifetime
var tickedCard = CardFor(screen, ticked);
// Lit first, and it matters: a Ctrl-click folds the lit card into the set it starts — see
// ExtendHostChoice — so ticking one card while another is lit would leave no card outside the
// set for the second half of this test to right-click.
vault.SelectedHost = ticked;
Dispatcher.UIThread.RunJobs();
Click(tickedCard, window, RawInputModifiers.Control);
RightClick(tickedCard, window);
@@ -51,6 +51,18 @@ internal static class LayoutHarness
/// </remarks>
internal const double HostDrawerWidth = 320;
/// <summary>The splash window's fixed width, from <c>SplashWindow.axaml</c>.</summary>
/// <remarks>
/// Its height is deliberately absent from this list, because the window has none to state: it is
/// <c>SizeToContent="Height"</c> so that a failure message of any length is readable rather than
/// clipped. <see cref="SplashMinimumHeight"/> is the floor under the ordinary one-line case, and
/// <c>SplashCardTests</c> pins all three against the XAML.
/// </remarks>
internal const double SplashWidth = 420;
/// <inheritdoc cref="SplashWidth" />
internal const double SplashMinimumHeight = 160;
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
internal const double NavRailWidth = 255;
@@ -210,32 +210,38 @@ public sealed class NavRailTests : IAsyncLifetime
}
/// <summary>
/// Settings, Vaults and Preferences each enter settings mode on the page they promise, and shut the
/// popover behind them.
/// Settings enters settings mode on its landing page, and shuts the popover behind it.
/// </summary>
/// <remarks>
/// <para>
/// v5c: these three used to land on a bare <c>ShellScreen</c> — Settings and Preferences on the very
/// same one, since the mock's own Settings area did not exist yet. Now that it does, each opens the
/// settings mode on its own page — see <see cref="MainWindowViewModel.EnterSettings"/> — and "Settings"
/// and "Preferences" are no longer the same click.
/// </para>
/// <para>
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>SettingsPage</c>
/// is <c>internal</c>, and a public theory method may not carry an internal type in its signature.
/// </para>
/// v5c gave this menu three navigation rows — Settings, Vaults and Preferences — because settings mode
/// had just been built out of what were two bare <c>ShellScreen</c>s, and "Settings" and "Preferences"
/// had been the same click until then. All three opened the same mode and differed only in which page
/// they landed on, which made two of them deep links into a place the third one opens, past a
/// <c>SettingsNav</c> that lists every page anyway. One row now; see <c>NavRail.axaml</c>'s own remark.
/// </remarks>
[Fact]
public Task ThePopoversSettingsRow_EntersSettingsOnGeneralAndClosesThePopover() =>
APopoverRowLandsOnAsync("Settings", SettingsPage.General);
/// <summary>
/// The two rows that were folded into it are gone from the menu rather than merely unwired.
/// </summary>
/// <remarks>
/// Worth a test of its own because the failure it guards is silent: a row left in the markup and
/// stripped of its handler is a menu entry that does nothing when pressed, which reads as a broken
/// application rather than as a tidied menu. Both destinations are still reachable — one click deeper,
/// from <c>SettingsNav</c> — which is the whole argument for removing them from here.
/// </remarks>
[Fact]
public Task ThePopoversVaultsRow_EntersSettingsOnVaultsAndClosesThePopover() =>
APopoverRowLandsOnAsync("Vaults", SettingsPage.Vaults);
public Task ThePopoverNoLongerOffersVaultsOrPreferencesOfItsOwn() =>
OnTheRailAsync((rail, window) =>
{
Click(UserChip(rail), window);
[Fact]
public Task ThePopoversPreferencesRow_EntersSettingsOnPreferencesAndClosesThePopover() =>
APopoverRowLandsOnAsync("Preferences", SettingsPage.Preferences);
PopoverRows(window).ShouldNotContain("Vaults");
PopoverRows(window).ShouldNotContain("Preferences");
PopoverRows(window).ShouldContain("Settings");
});
private Task APopoverRowLandsOnAsync(string label, SettingsPage target) =>
OnTheRailAsync((rail, window) =>
@@ -369,6 +375,24 @@ public sealed class NavRailTests : IAsyncLifetime
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
/// <summary>
/// Every word the open popover offers as a place to go.
/// </summary>
/// <remarks>
/// The vault switches are excluded by their data context rather than by their label, because a vault is
/// free to be called anything — including "Vaults" — and a test asserting that a menu entry is absent
/// must not be satisfiable or defeatable by what somebody named a vault.
/// </remarks>
private static IReadOnlyList<string> PopoverRows(Visual window) =>
[.. window.GetVisualDescendants()
.OfType<Button>()
.Where(button => button.Classes.Contains("poprow")
&& button.DataContext is not VaultToggleViewModel)
.SelectMany(button => button.GetVisualDescendants().OfType<TextBlock>())
.Select(text => text.Text)
.Where(text => !string.IsNullOrEmpty(text))
.Select(text => text!)];
/// <summary>Whether a vault row's magenta check square is drawn, for whether it is currently shown.</summary>
private static bool HasVisibleCheck(Button row) =>
row.GetVisualDescendants().OfType<Border>().Any(
@@ -942,6 +942,49 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab);
}
/// <remarks>
/// <para>
/// The card is the one place the shell says what it is dialling <em>before</em> anything answers, and on a
/// refusal it is the only copy of that on screen — so both lines are there to be taken away, and a
/// <c>TextBlock</c> in Avalonia is a drawing of a string rather than a string. Same change, same argument,
/// as the session column's own address; see <c>SessionSidebarTests</c>.
/// </para>
/// <para>
/// Asserted on values long enough to be trimmed, which is the half that could quietly break: an ellipsis
/// is a drawing and not a shorter string, so select-all has to reach the whole name and the whole address
/// rather than the part that happened to fit inside the card's 460 pixels. Values that fit would pass
/// whether that were true or not.
/// </para>
/// </remarks>
[Fact]
public async Task TheConnectingCardsNameAndAddressSelectAndCopyWhole()
{
const string label = "customer-production-database-01.eu-west-1.internal.example.company";
const string address = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022";
await OnTheConnectingCardAsync(
card =>
{
// Found by their text rather than by position: the order is the visual tree's, and a third
// selectable block is already in there — the refusal reason, hidden while this tab is only
// connecting. Single rather than First, so a duplicate would be a question rather than a
// silent pick.
var takeable = card.GetVisualDescendants().OfType<SelectableTextBlock>().ToList();
var name = takeable.Single(block => block.Text == label);
var endpoint = takeable.Single(block => block.Text == address);
name.SelectAll();
name.SelectedText.ShouldBe(label, "the ellipsis is a drawing, not a shorter string");
name.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
endpoint.SelectAll();
endpoint.SelectedText.ShouldBe(address, "the ellipsis is a drawing, not a shorter string");
endpoint.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
},
new TerminalTabViewModel(label, address));
}
// ---- The logs screen ----
[Fact]
@@ -2043,6 +2086,40 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
},
Token);
/// <summary>Lays the connecting card out and hands it over, rather than measuring it.</summary>
/// <remarks>
/// The same rectangle <see cref="MeasureConnectingAsync"/> uses and for the same reasons — see its
/// remark. Separate because the claim is about a control's own state rather than about where anything was
/// arranged, and threading an assertion over the card through a method whose parameter is a fault list
/// would make both callers harder to read than two methods are.
/// </remarks>
private Task OnTheConnectingCardAsync(Action<ConnectingCard> body, TerminalTabViewModel tab) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.State = ShellState.Unlocked;
shell.Tabs.Clear();
shell.Tabs.Add(tab);
shell.SelectedTab = tab;
var card = new ConnectingCard { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
card,
LayoutHarness.SessionScreenWidth(sidebarVisible: true),
LayoutHarness.SessionScreenHeight);
try
{
body(card);
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
/// <remarks>
/// <para>
@@ -416,6 +416,32 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
await ScannedImportAsync());
}
// ---- The Hetzner wizard ----
//
// Measured against the same SettingsContentWidth/SettingsContentHeight budget, because it is drawn in
// the same content column over the same Preferences page — see MainWindowViewModel.IsHetznerImportOpen.
// Two shapes, because the two steps are different layouts rather than one with a section hidden: the
// token step is a card, and the choose step is a table with an options card above it and a footer below.
[Fact]
public async Task TheHetznerWizardFitsOnTheStepItOpensOn()
{
await MeasureHetznerAsync(faults => faults.ShouldBeEmpty("the token step"));
}
/// <remarks>
/// The shape with something to decide about, and the two rows that must not be pushed off the bottom are
/// the sentence saying no key comes with these hosts and the IMPORT button under it — the same pair
/// <see cref="TheImportScreenFitsWithHostsToChooseFromAndWarnings"/> is written about.
/// </remarks>
[Fact]
public async Task TheHetznerWizardFitsWithServersToChooseFromAndWarnings()
{
await MeasureHetznerAsync(
faults => faults.ShouldBeEmpty("with a scanned project"),
await ScannedHetznerAsync());
}
// ---- Helpers ----
/// <remarks>
@@ -538,4 +564,93 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
Directory.Delete(directory, recursive: true);
}
}
/// <inheritdoc cref="MeasureImportAsync" />
private Task MeasureHetznerAsync(
Action<IReadOnlyList<string>> assert,
HetznerImportViewModel? wizard = null) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new HetznerImportScreen
{
DataContext = wizard ?? new HetznerImportViewModel(vault, new HetznerCloudClient()),
};
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>
/// A wizard that has read a project, so the table has rows in it.
/// </summary>
/// <remarks>
/// Through the real client over a stubbed transport rather than by populating the rows directly, on the
/// reasoning <see cref="ScannedImportAsync"/> gives: a row carrying a warning is taller than one without,
/// and inventing the rows would measure a layout nothing generates. The fixture is chosen to produce all
/// three chip states plus a project-level warning — a plain server, one with no IPv4, and one the API
/// described without a name.
/// </remarks>
private async Task<HetznerImportViewModel> ScannedHetznerAsync()
{
var http = new HttpClient(new StubHetzner("""
{
"servers": [
{
"id": 1,
"name": "web-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.10" } },
"server_type": { "name": "cx22" },
"datacenter": { "location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" } },
"image": { "name": "ubuntu-24.04" }
},
{
"id": 2,
"name": "edge-v6",
"status": "off",
"public_net": { "ipv6": { "ip": "2a01:4f8:1c17:d00::/64" } },
"server_type": { "name": "cax11" },
"datacenter": { "location": { "name": "hel1", "city": "Helsinki", "country": "FI" } }
},
{ "id": 3, "status": "initializing" }
],
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": null } }
}
"""));
var wizard = new HetznerImportViewModel(vault, new HetznerCloudClient(http))
{
Token = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
};
await wizard.ScanCommand.ExecuteAsync(null);
wizard.HasRows.ShouldBeTrue("the fixture has servers in it");
wizard.HasWarnings.ShouldBeTrue("the fixture has a server the API left unnamed");
return wizard;
}
/// <summary>Answers anything asked of it with one page. There is no sequence to model here.</summary>
private sealed class StubHetzner(string body) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"),
});
}
}
@@ -0,0 +1,128 @@
using Avalonia.Controls;
using DodoSSH.Client.App.Views;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The splash, and the one thing about it that can go wrong.
/// </summary>
/// <remarks>
/// <para>
/// Three stacked elements in a fixed-width window is not a layout with much to get wrong — except in one
/// state. <c>SplashWindow.CouldNotStart</c> carries an exception's own message, whose length nothing in this
/// application decides, and that message is the whole reason that window is still on screen. Inside a fixed
/// height it would wrap and be clipped at exactly the wrong moment.
/// </para>
/// <para>
/// So what is measured here is that the message grows the card, and that the window is declared in a way
/// that lets the growth through. <c>LayoutHarness.Unreachable</c> is no help: it reports interactive controls
/// arranged where they cannot be used, and the splash has none — which is why these two assert on measured
/// heights instead.
/// </para>
/// </remarks>
public sealed class SplashCardTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <summary>A message about as long as a real one gets: a path, a reason and a suggestion.</summary>
private const string LongFailure =
"The process cannot access the file 'C:\\Users\\somebody\\AppData\\Local\\DodoSSH\\cache.db' "
+ "because it is being used by another process, and this build cannot open a second copy of it.";
/// <remarks>
/// Pins the two constants and the mechanism against the XAML, the way
/// <c>LayoutHarnessTests.TheHarnessMeasuresTheSizeTheWindowDeclares</c> pins the main window's minimum.
/// <c>SizeToContent</c> is the one that matters: a window that lost it would clip the message and this
/// suite's other test would go on passing, because the card would still be measuring the taller size it
/// was never given.
/// </remarks>
[Fact]
public async Task TheSplashIsDeclaredTheWayTheHarnessMeasuresIt()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var window = new SplashWindow();
try
{
window.Width.ShouldBe(LayoutHarness.SplashWidth);
window.MinHeight.ShouldBe(LayoutHarness.SplashMinimumHeight);
window.SizeToContent.ShouldBe(
SizeToContent.Height,
"a fixed height would clip a long failure message");
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The ordinary state has to fit the floor, or the window opens taller than its own minimum for the one
/// line it normally shows — which would make <see cref="LayoutHarness.SplashMinimumHeight"/> a number
/// that never applies to anything.
/// </remarks>
[Fact]
public async Task TheStartingMessageFitsTheHeightTheSplashOpensAt()
{
await LayoutHarness.OnTheUiThreadAsync(
() => MessageHeight(card => { }).ShouldBeLessThanOrEqualTo(
LayoutHarness.SplashMinimumHeight,
"the splash opens at its minimum and the one line it starts with has to fit"),
Token);
}
/// <remarks>
/// ◆ The test with something behind it. A message that wraps to three lines has to make the card ask for
/// more room; if it did not, the text would be sitting under the bottom edge of the window and the only
/// thing the splash exists to carry at that point would be the part nobody could read.
/// </remarks>
[Fact]
public async Task ALongFailureMessageAsksForMoreRoomRatherThanBeingClipped()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var starting = MessageHeight(card => { });
var failed = MessageHeight(card => card.CouldNotStart(LongFailure));
failed.ShouldBeGreaterThan(
starting,
"the message wraps, so the card has to want the height the window then gives it");
},
Token);
}
/// <summary>
/// What one card wants to be, at the splash's width, after the caller has had its say.
/// </summary>
/// <remarks>
/// Hosted in a window before being measured, because the heading and the hint take their size and their
/// wrapping from <c>App.axaml</c> — a card measured detached would be measuring unstyled text. Measured
/// again afterwards against an unbounded height, which is what the real window's <c>SizeToContent</c>
/// gives it and what the host window's own fixed height would otherwise clamp away.
/// </remarks>
private static double MessageHeight(Action<SplashCard> say)
{
var card = new SplashCard();
var window = LayoutHarness.HostAtMinimumSize(
card, LayoutHarness.SplashWidth, LayoutHarness.SplashMinimumHeight);
try
{
say(card);
card.Measure(new Avalonia.Size(LayoutHarness.SplashWidth, double.PositiveInfinity));
return card.DesiredSize.Height;
}
finally
{
window.Close();
}
}
}
@@ -71,6 +71,63 @@ internal sealed partial class FakeRenderer : IAsyncDisposable
return renderer;
}
/// <summary>Sends a client frame, as the page would.</summary>
/// <remarks>
/// Raw rather than through a helper per opcode: the two this exists for carry a session id and either
/// UTF-8 or nothing at all, and a method each would be three lines of ceremony around
/// <see cref="TerminalFrame.Create"/>.
/// </remarks>
internal async Task SendAsync(
TerminalClientOpcode opcode,
uint sessionId,
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken) =>
await socket
.SendAsync(
TerminalFrame.Create((byte)opcode, sessionId, payload.Span),
WebSocketMessageType.Binary,
endOfMessage: true,
cancellationToken)
.ConfigureAwait(false);
/// <summary>Reads frames until one carries that opcode, and answers with it.</summary>
/// <remarks>
/// <para>
/// Skipping rather than expecting the next frame to be the interesting one, because a live session's
/// socket is not quiet: the shell's opening frame, its activation and whatever the remote said are all
/// on the way, and which of them have arrived by the time a test looks is a scheduling accident.
/// </para>
/// <para>
/// Bounded only by the caller's token, which in this suite is the test's own — so a frame that never
/// comes fails the test on its timeout rather than hanging the run.
/// </para>
/// </remarks>
internal async Task<byte[]> WaitForAsync(
TerminalServerOpcode opcode,
CancellationToken cancellationToken)
{
var buffer = new byte[64 * 1024];
while (true)
{
var result = await socket
.ReceiveAsync(buffer.AsMemory(), cancellationToken)
.ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
throw new InvalidOperationException(
$"The host closed the socket before sending a {opcode} frame.");
}
if (TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var read, out _, out var payload)
&& read == (byte)opcode)
{
return payload.ToArray();
}
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
+70 -1
View File
@@ -45,6 +45,31 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
/// </remarks>
internal List<SshConnectionRequest> SftpRequests { get; } = [];
/// <summary>
/// The furthest phase a failing attempt reports before <see cref="Failure"/> is thrown, or null for one
/// that never reports any.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>It is what decides whether the shell goes and probes the endpoint.</b> A connection that stopped
/// while reaching is worth asking a socket about; one that got a host key has plainly reached the machine,
/// and one that never reported a phase never dialled at all. See <c>VaultViewModel.ExplainAsync</c>.
/// </para>
/// <para>
/// Defaults to <see cref="SshConnectionPhase.Reaching"/>, which is what this fake reported
/// unconditionally before the field existed, so every test written against it goes on meaning what it did.
/// Null models the real factory throwing out of <c>BuildConnectionInfo</c> — a private key that will not
/// parse — which happens before it reports anything.
/// </para>
/// </remarks>
internal SshConnectionPhase? FailAfter { get; set; } = SshConnectionPhase.Reaching;
/// <summary>Every phase up to and including <paramref name="furthest"/>, in the order they happen.</summary>
private static IEnumerable<SshConnectionPhase> PhasesUpTo(SshConnectionPhase? furthest) =>
furthest is { } limit
? Enum.GetValues<SshConnectionPhase>().Where(phase => phase <= limit)
: [];
/// <inheritdoc />
public async Task<ISshConnection> ConnectAsync(
SshConnectionRequest request,
@@ -56,7 +81,12 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
// Before the gate rather than after it, which is what makes this fake useful for the connecting
// card: a test that holds Gate open is a connection stuck partway through, and the step list has to
// show it stuck on a named step rather than on none.
progress?.Report(SshConnectionPhase.Reaching);
//
// How far a failing attempt gets is FailAfter's to say; a succeeding one is always reaching by now.
foreach (var phase in Failure is null ? [SshConnectionPhase.Reaching] : PhasesUpTo(FailAfter))
{
progress?.Report(phase);
}
if (Gate is { } gate)
{
@@ -419,3 +449,42 @@ internal sealed class FakeSshShellSession : ISshShellSession
closed.Dispose();
}
}
/// <summary>
/// A reachability probe that answers from a script instead of from the network.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Every shell in this suite is given one, and that is not only about speed.</b> The real probe resolves
/// a name and opens a socket — see <see cref="SshReachabilityProbe"/> — so a fixture without this would put a
/// live DNS lookup for <c>db.internal</c> and a live TCP connect in the middle of a unit suite, on every test
/// where a connection fails. That is slow, it needs a network, and it is worse than either: <c>db.internal</c>
/// resolves on any machine whose resolver has a search domain that makes it resolve, so the sentence the
/// assertions read would depend on whose network the suite was run from.
/// </para>
/// <para>
/// <see cref="Summary"/> defaults to empty, which is what keeps the tests that predate the probe asserting
/// the exact message they always did: an empty finding appends nothing, so those cases go on describing a
/// failure and nothing else. A test that is about the diagnosis sets it.
/// </para>
/// </remarks>
internal sealed class FakeReachabilityProbe : ISshReachabilityProbe
{
/// <summary>What every probe comes back with. Empty adds nothing to the failure it explains.</summary>
internal string Summary { get; set; } = string.Empty;
/// <summary>What it was asked about, in order, so a test can assert it was not asked at all.</summary>
internal List<string> Probed { get; } = [];
/// <inheritdoc />
public ValueTask<SshReachabilityReport> ProbeAsync(
string host,
int port,
CancellationToken cancellationToken)
{
Probed.Add($"{host}:{port}");
return ValueTask.FromResult(
new SshReachabilityReport(SshReach.Open, host, port, host, TimeSpan.Zero, Summary));
}
}
@@ -0,0 +1,523 @@
using System.Net;
using System.Text;
using DodoSSH.Client.Import;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The Hetzner Cloud wizard, from a token to hosts on the server.
/// </summary>
/// <remarks>
/// <para>
/// What this establishes beyond <c>HetznerCloudClientTests</c> is the half that suite cannot reach: that
/// reading a project stores nothing, that importing goes through the ordinary create-and-push path every
/// other write uses, that a server the keychain already points at arrives unticked rather than silently
/// duplicated, and that the token does not outlive the request it was typed for.
/// </para>
/// <para>
/// Hetzner is a stubbed <c>HttpMessageHandler</c>. Everything else — the vault, the cache, the server — is
/// this fixture's real one, which is the point: the assertion that matters most is that the imported hosts
/// reached the server without anything pressing Sync.
/// </para>
/// </remarks>
public sealed partial class ShellFlowTests
{
private const string HetznerToken =
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
/// <summary>
/// The whole wizard.
/// </summary>
/// <remarks>
/// One server already in the keychain and one not, so the two halves of the default are both exercised:
/// the new one arrives ticked, the known one does not, and only the ticked one is stored.
/// </remarks>
[Fact]
public async Task ImportingFromHetzner_ShowsTheProjectFirstAndThenStoresWhatWasTicked()
{
await UnlockedAsync();
var vault = shell.Vault!;
// db.internal:deploy is what AddHostAsync creates, so the first server is one the keychain has.
await AddHostAsync(vault, "prod-db");
var wizard = Wizard(Project("""
{
"id": 1,
"name": "already-here",
"status": "running",
"public_net": { "ipv4": { "ip": "db.internal" } }
}
""",
"""
{
"id": 2,
"name": "web-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.10" } },
"server_type": { "name": "cx22" },
"datacenter": { "name": "fsn1-dc14", "location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" } },
"image": { "name": "ubuntu-24.04" }
}
"""));
wizard.Step.ShouldBe(HetznerImportStep.Token, "it opens on the box, having sent nothing");
wizard.Token = HetznerToken;
wizard.Username = "deploy";
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Choose);
wizard.Rows.Count.ShouldBe(2);
vault.Hosts.Count.ShouldBe(1, "reading the project stores nothing");
var known = wizard.Rows.Single(row => string.Equals(row.Name, "already-here", StringComparison.Ordinal));
known.AlreadyPresent.ShouldBeTrue("it points at a machine the keychain already has");
known.IsSelected.ShouldBeFalse("a duplicate takes a click rather than being the default");
var fresh = wizard.Rows.Single(row => string.Equals(row.Name, "web-01", StringComparison.Ordinal));
fresh.IsSelected.ShouldBeTrue();
fresh.Address.ShouldBe("203.0.113.10");
await wizard.ImportCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Done);
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
imported.Address.ShouldBe("deploy@203.0.113.10:22");
imported.Host.SshKeyId.ShouldBeNull("Hetzner has no private key to hand over");
imported.Host.Notes.ShouldNotBeNull().ShouldContain("cx22");
vault.Hosts.Count.ShouldBe(2, "only the ticked one was stored");
// Through the ordinary path, which is the point of routing it through the vault: it reached the
// server without anything pressing Sync.
server.LiveRowCount.ShouldBe(2);
}
/// <summary>
/// The token does not outlive the request it was typed for.
/// </summary>
/// <remarks>
/// This is the whole of what makes pasting a credential capable of listing somebody's estate into a text
/// box acceptable: it is used once and dropped. Asserted on the success path deliberately — a wizard
/// that cleared the box only when something went wrong would leave the token sitting there for the
/// entire time somebody spends reading a list of forty servers.
/// </remarks>
[Fact]
public async Task TheTokenIsForgottenAsSoonAsTheProjectHasBeenRead()
{
await UnlockedAsync();
var wizard = Wizard(Project(WebServer));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Token.ShouldBeEmpty();
wizard.Rows.ShouldHaveSingleItem();
}
/// <remarks>
/// A refused token is the failure this meets most often, and the recovery is to type a different one —
/// so it stays on the step that has the box, with a message that says where tokens come from.
/// </remarks>
[Fact]
public async Task ARefusedToken_StaysOnTheTokenStepAndStoresNothing()
{
await UnlockedAsync();
var wizard = Wizard(new StubHetznerResponse(
HttpStatusCode.Unauthorized,
"""{ "error": { "code": "unauthorized", "message": "unable to authenticate" } }"""));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Token);
wizard.Status.ShouldContain("API tokens");
wizard.Rows.ShouldBeEmpty();
wizard.Token.ShouldBeEmpty("a refused token is retyped rather than corrected in place");
shell.Vault!.Hosts.ShouldBeEmpty();
}
/// <remarks>
/// An empty project is a correct answer to a correct token, so it is not an error — and it is not a step
/// forward either, because a list screen with nothing on it says less than the sentence does.
/// </remarks>
[Fact]
public async Task AnEmptyProject_SaysSoWithoutMovingOn()
{
await UnlockedAsync();
var wizard = Wizard(Project());
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Token);
wizard.Status.ShouldContain("no servers");
}
/// <summary>
/// Choosing the private network does not quietly put a public address in the keychain.
/// </summary>
/// <remarks>
/// The one asymmetry in how addresses resolve, and the reason for it: falling back between v4 and v6
/// reaches the same machine over the same internet, where 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 the row loses its tick and cannot get it back while that choice stands.
/// </remarks>
[Fact]
public async Task ChoosingThePrivateNetwork_LeavesAServerWithoutOneUnimportable()
{
await UnlockedAsync();
var wizard = Wizard(Project(WebServer, """
{
"id": 3,
"name": "db-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.20" } },
"private_net": [ { "ip": "10.0.0.4" } ]
}
"""));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.SelectedCount.ShouldBe(2);
wizard.UseAddressCommand.Execute(HetznerAddressKind.Private);
var public_ = wizard.Rows.Single(row => string.Equals(row.Name, "web-01", StringComparison.Ordinal));
var inside = wizard.Rows.Single(row => string.Equals(row.Name, "db-01", StringComparison.Ordinal));
public_.CanImport.ShouldBeFalse("it is on no private network");
public_.IsSelected.ShouldBeFalse();
public_.Address.ShouldBe("—");
inside.CanImport.ShouldBeTrue();
inside.Address.ShouldBe("10.0.0.4");
wizard.SelectedCount.ShouldBe(1);
// Neither direction of "tick everything" can reach it, which is what "not importable" has to mean.
wizard.ToggleAllCommand.Execute(null);
public_.IsSelected.ShouldBeFalse();
inside.IsSelected.ShouldBeFalse("that press was the untick, since everything reachable was ticked");
wizard.ToggleAllCommand.Execute(null);
public_.IsSelected.ShouldBeFalse();
inside.IsSelected.ShouldBeTrue();
await wizard.ImportCommand.ExecuteAsync(null);
shell.Vault!.Hosts.ShouldHaveSingleItem().Host.Hostname.ShouldBe("10.0.0.4");
}
/// <remarks>
/// Changing the address choice must not throw away ticks somebody set — but changing the login user has
/// to re-ask "is this already here", because the user is half of what that is decided on.
/// </remarks>
[Fact]
public async Task ChangingTheLoginUser_ReasksWhetherTheKeychainAlreadyHasIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
var wizard = Wizard(Project("""
{
"id": 1,
"name": "db",
"status": "running",
"public_net": { "ipv4": { "ip": "db.internal" } }
}
"""));
wizard.Token = HetznerToken;
wizard.Username = "deploy";
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Rows.ShouldHaveSingleItem().AlreadyPresent.ShouldBeTrue();
wizard.Username = "ubuntu";
wizard.Rows[0].AlreadyPresent.ShouldBeFalse("a different account on that machine is a different host");
}
/// <summary>
/// A server with no public IPv4 still imports, and the row says what was assumed to reach it.
/// </summary>
/// <remarks>
/// IPv6-only is a whole Hetzner price tier rather than an edge case. The address is the first in the
/// routed /64 — the API reports a network, which nothing can dial — and that is a convention of
/// Hetzner's own images rather than a fact, so it is on the row and in the stored note.
/// </remarks>
[Fact]
public async Task AnIPv6OnlyServer_ImportsWithWhatWasAssumedWrittenDown()
{
await UnlockedAsync();
var wizard = Wizard(Project("""
{
"id": 5,
"name": "v6-only",
"status": "running",
"public_net": { "ipv4": null, "ipv6": { "ip": "2a01:4f8:1c17:d00::/64" } }
}
"""));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
var row = wizard.Rows.ShouldHaveSingleItem();
row.Address.ShouldBe("2a01:4f8:1c17:d00::1");
row.HasWarning.ShouldBeTrue();
row.Meaning.ShouldContain("No public IPv4");
await wizard.ImportCommand.ExecuteAsync(null);
var host = shell.Vault!.Hosts.ShouldHaveSingleItem();
host.Host.Hostname.ShouldBe("2a01:4f8:1c17:d00::1");
host.Host.Notes.ShouldNotBeNull().ShouldContain("No public IPv4");
}
/// <remarks>
/// After an import the list is asked again rather than cleared, so a row that was just stored says
/// "already here" — which is what makes coming back and importing the rest of the project harmless and
/// visible rather than harmless and confusing.
/// </remarks>
[Fact]
public async Task AfterImporting_TheListSaysWhichOnesAreNowHere()
{
await UnlockedAsync();
var wizard = Wizard(Project(WebServer));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
await wizard.ImportCommand.ExecuteAsync(null);
wizard.ReviewCommand.Execute(null);
wizard.Step.ShouldBe(HetznerImportStep.Choose);
var row = wizard.Rows.ShouldHaveSingleItem();
row.AlreadyPresent.ShouldBeTrue();
row.IsSelected.ShouldBeFalse("importing it twice takes a click");
}
// ---- Where it lives in the shell ----
/// <remarks>
/// The wizard is drawn over the Preferences page inside settings mode, exactly as the ssh_config
/// importer is: the nav stays lit on Preferences and the titlebar offers "Back to preferences". What
/// would silently come apart is the content column — two importers and one page beneath them.
/// </remarks>
[Fact]
public async Task TheHetznerWizardOpensOverPreferencesAndBacksOutToIt()
{
await UnlockedAsync();
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
shell.IsHetznerImportOpen.ShouldBeTrue();
shell.IsImporterOpen.ShouldBeTrue("the titlebar's back button asks this one");
shell.IsSettingsPreferencesPage.ShouldBeTrue("the nav stays lit on Preferences");
shell.IsSettingsPreferencesContentShowing.ShouldBeFalse("the wizard is what the column draws");
shell.CloseImportCommand.Execute(null);
shell.IsHetznerImportOpen.ShouldBeFalse();
shell.IsSettingsMode.ShouldBeTrue("back to preferences, not out of settings");
shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
}
/// <remarks>
/// Opening one closes the other. They share a content column, and two importers drawn over each other
/// would leave whichever was on top looking like the one the back button belongs to.
/// </remarks>
[Fact]
public async Task OpeningOneImporterClosesTheOther()
{
await UnlockedAsync();
shell.ShowScreenCommand.Execute(ShellScreen.Import);
shell.IsImportOpen.ShouldBeTrue();
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
shell.IsImportOpen.ShouldBeFalse();
shell.IsHetznerImportOpen.ShouldBeTrue();
shell.ShowScreenCommand.Execute(ShellScreen.Import);
shell.IsHetznerImportOpen.ShouldBeFalse();
shell.IsImportOpen.ShouldBeTrue();
}
/// <summary>
/// Reopening the wizard puts it back on step one.
/// </summary>
/// <remarks>
/// A decision rather than an omission. The first step holds a credential this deliberately does not
/// keep, so a "resumed" second step would be a list nothing on screen has the means to refresh — and it
/// would go on looking current for as long as somebody left the application open.
/// </remarks>
[Fact]
public async Task ReopeningTheWizard_StartsAtTheTokenAgain()
{
await UnlockedAsync();
var wizard = shell.HetznerImportScreen.ShouldNotBeNull();
// Driven through the shell's own view model rather than the one this file builds, because what is
// being checked is what OpenHetznerImport does to the screen the shell is holding.
wizard.Token = HetznerToken;
wizard.Step = HetznerImportStep.Choose;
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
wizard.Step.ShouldBe(HetznerImportStep.Token);
wizard.Rows.ShouldBeEmpty();
}
// ---- Helpers ----
/// <summary>A server carrying enough to be imported, used wherever the shape is not what is being tested.</summary>
/// <summary>
/// The two answers this import used to decide on somebody's behalf: which vault, and which key.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A Hetzner project is the case where "wherever I happen to be standing" is the wrong vault.</b>
/// The <c>ssh_config</c> importer reads this machine's own account and files into the active vault
/// fairly; a project belongs to a team more often than to a person, and forty servers landing in
/// somebody's personal vault are either forty items nobody else can see or forty moved one at a time.
/// </para>
/// <para>
/// <b>The key is the half Hetzner cannot supply and the keychain usually already holds.</b> The public
/// half went to Hetzner when the servers were made; the private half never did. So the import points at
/// a key rather than bringing one — the host stores its id, exactly as one bound by hand does, and the
/// material is neither read nor copied. Without it this screen's honest answer was forty hosts asking
/// for a password.
/// </para>
/// <para>
/// The key here is deliberately left in the personal vault while the hosts go to the shared one, which
/// is allowed and is the one thing about it worth saying out loud: a binding resolves across every vault
/// <em>this</em> account can read, so these hosts connect here and not for a colleague who holds only
/// the destination's key.
/// </para>
/// </remarks>
[Fact]
public async Task ImportingIntoAChosenVault_BindsEveryServerToTheChosenKey()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = "Platform secrets";
await vaults.CreateVaultCommand.ExecuteAsync(null);
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
await AddKeyAsync(vault, "deploy");
var keyId = vault.Keys.ShouldHaveSingleItem().EntityId;
var wizard = Wizard(Project(WebServer));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.HasVaultChoice.ShouldBeTrue("there are two vaults to choose between");
wizard.HasKeyChoice.ShouldBeTrue("the keychain has a key to point at");
wizard.SelectedKey.ShouldNotBeNull().KeyId.ShouldBeNull("binding a key is chosen, never defaulted");
wizard.SelectedVault = wizard.VaultChoices.Single(choice => choice.VaultId == sharedVaultId);
wizard.SelectedKey = wizard.KeyChoices.Single(choice => choice.KeyId == keyId);
wizard.AuthenticationNote.ShouldContain("deploy");
wizard.AuthenticationNote.ShouldContain(
"another vault",
Case.Sensitive,
"the key stayed behind, and the colleagues these hosts just joined cannot resolve it");
await wizard.ImportCommand.ExecuteAsync(null);
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
imported.VaultId.ShouldBe(sharedVaultId, wizard.Status);
imported.Host.SshKeyId.ShouldBe(keyId, "pointed at the key rather than carrying one");
wizard.Status.ShouldContain("Platform secrets");
// Through the ordinary path, as every other write here is: it reached the server without anything
// pressing Sync.
server.LiveRowCount.ShouldBe(2, "the key and the host");
}
private const string WebServer = """
{
"id": 2,
"name": "web-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.10" } }
}
""";
/// <summary>The wizard, over a Hetzner that answers with exactly this.</summary>
private HetznerImportViewModel Wizard(StubHetznerResponse response) =>
new(shell.Vault!, new HetznerCloudClient(new HttpClient(new StubHetzner(response))));
/// <summary>One page holding these servers and saying there are no more.</summary>
private static StubHetznerResponse Project(params string[] servers) =>
new(
HttpStatusCode.OK,
$$"""
{
"servers": [ {{string.Join(",", servers)}} ],
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": null } }
}
""");
private sealed record StubHetznerResponse(HttpStatusCode Status, string Body);
/// <summary>Answers every request the same way. What varies between these tests is the body, not the sequence.</summary>
private sealed class StubHetzner(StubHetznerResponse response) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(response.Status)
{
Content = new StringContent(response.Body, Encoding.UTF8, "application/json"),
});
}
}
@@ -26,13 +26,27 @@ namespace DodoSSH.Client.App.Tests;
/// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test
/// rather than by remembering to click through them.
/// </remarks>
public sealed class ShellFlowTests : IAsyncLifetime
/// <remarks>
/// Split across two files rather than one: the Hetzner wizard's own path is in
/// <c>ShellFlowTests.Hetzner.cs</c>, which needs this fixture's unlocked vault and nothing else of it. The
/// same reason <c>FakeVaultServer</c> keeps its team endpoints in a file of their own.
/// </remarks>
public sealed partial class ShellFlowTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
/// <summary>Everything this shell has copied, newest last.</summary>
private readonly List<string> clipboard = [];
/// <summary>
/// What the machine's clipboard holds, as far as this shell can read it.
/// </summary>
/// <remarks>
/// Null rather than empty by default, which is what a clipboard holding something that is not text
/// answers. Set by the one test that needs the shell to read one — the terminal's right-click paste.
/// </remarks>
private string? machineClipboard;
/// <remarks>
/// Far below the shipped profile, for the same reason as everywhere else: these tests are about the
/// state machine, not about how expensive the passphrase is to attack.
@@ -49,6 +63,15 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// </remarks>
private readonly FakeSshConnectionFactory ssh = new();
/// <summary>
/// The scripted answer behind every "why did that not connect" in this file.
/// </summary>
/// <remarks>
/// Silent by default — see <see cref="FakeReachabilityProbe.Summary"/> — so the cases written before the
/// diagnosis existed go on asserting the exact failure message they always did.
/// </remarks>
private readonly FakeReachabilityProbe reachability = new();
private int signInAttempts;
/// <summary>How many times a shell has tried to resume a remembered sign-in, and with what.</summary>
@@ -164,6 +187,10 @@ public sealed class ShellFlowTests : IAsyncLifetime
return Task.CompletedTask;
},
// And the other direction, which only the terminal's right-click paste uses. A closure over the
// field rather than a value captured now, because a test sets it after the shell is built.
readClipboard: () => Task.FromResult(machineClipboard),
// Inline, because this suite has no window and therefore no dispatcher to drain — the same
// answer TransferQueueingTests reached, and for the reason its own remark gives: reaching
// Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched
@@ -194,7 +221,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
{
save = onChanged;
return new StopWatching(() => save = null);
});
},
// Scripted rather than real, so that a failed connection in any test in this file does not go
// and do a DNS lookup and open a socket. See FakeReachabilityProbe for why that matters beyond
// the wall clock.
reachability: reachability);
return ValueTask.CompletedTask;
}
@@ -294,6 +326,90 @@ public sealed class ShellFlowTests : IAsyncLifetime
}
}
// ---- The terminal's right-click, which is a copy or a paste ----
/// <remarks>
/// <para>
/// The gesture is heard by the page and nowhere else — the selection is drawn into a canvas, and a
/// focused terminal leaves the host's window no input events at all — so the shell's part is to be the
/// clipboard. This is that wiring end to end: a real frame on the real socket, through the workspace, to
/// the delegate a head supplies.
/// </para>
/// <para>
/// Sent with no session open at all, deliberately. A copy is not about a terminal, and the frame is
/// dispatched before the session lookup precisely so that copying the last thing a dead tab said still
/// works — which is when somebody is most likely to want it.
/// </para>
/// </remarks>
[Fact]
public async Task ARightClickCopy_ReachesTheMachinesClipboard()
{
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await renderer.SendAsync(
TerminalClientOpcode.ClipboardCopy,
sessionId: 0,
System.Text.Encoding.UTF8.GetBytes("error: nothing was written"),
Token);
await EventuallyAsync(
() => clipboard.Contains("error: nothing was written", StringComparer.Ordinal),
"the copy frame should have reached the clipboard delegate");
}
/// <remarks>
/// The other direction, and the half the page cannot do for itself: reading a clipboard from a WebView
/// needs the <c>clipboard-read</c> permission, which an embedded WebView answers with a prompt. So the
/// page asks, the shell reads, and the answer goes back down the same socket addressed to the pane that
/// asked — which is what this follows all the way round.
/// </remarks>
[Fact]
public async Task ARightClickPaste_SendsTheMachinesClipboardBackToThePaneThatAsked()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
var sessionId = shell.SelectedTab.ShouldNotBeNull().SessionId;
machineClipboard = "sudo systemctl restart sshd";
await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token);
var payload = await renderer.WaitForAsync(TerminalServerOpcode.Clipboard, Token);
System.Text.Encoding.UTF8.GetString(payload).ShouldBe("sudo systemctl restart sshd");
}
/// <remarks>
/// An empty clipboard is answered with nothing rather than with an empty paste, which the page would
/// bracket and hand the remote as a keystroke nobody made. Proven by the frame that <em>does</em> come
/// after it: the receive loop is sequential, so a clipboard frame for the second request arriving means
/// the first was seen and passed over.
/// </remarks>
[Fact]
public async Task ARightClickPaste_SendsNothingWhenTheClipboardHasNoText()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
var sessionId = shell.SelectedTab.ShouldNotBeNull().SessionId;
machineClipboard = null;
await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token);
machineClipboard = "uptime";
await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token);
var payload = await renderer.WaitForAsync(TerminalServerOpcode.Clipboard, Token);
System.Text.Encoding.UTF8.GetString(payload)
.ShouldBe("uptime", "the empty clipboard sent no frame of its own");
}
[Fact]
public async Task AFreshMachine_AsksForAServer()
{
@@ -2414,6 +2530,84 @@ public sealed class ShellFlowTests : IAsyncLifetime
tab.Steps[1].IsRunning.ShouldBeTrue("nothing moved it on after the shell let go of it");
}
/// <remarks>
/// <para>
/// ◆ <b>The reason this exists: "Connection has timed out." is what SSH.NET says for two different
/// faults.</b> A machine that never answered and a machine that answered TCP and then never spoke SSH are
/// the same five words and nothing else, and they are not the same problem. So a failure that stopped
/// while reaching now goes and looks, and what it finds is added to what was said.
/// </para>
/// <para>
/// The finding is appended rather than substituted, which is the half worth asserting: the exception is
/// the fact and the probe is a second measurement, and a test that only checked for the new sentence
/// would pass against an implementation that had thrown the original away.
/// </para>
/// </remarks>
[Fact]
public async Task AConnectionThatCouldNotReachItsHost_SaysWhatIsActuallyWrongWithIt()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new InvalidOperationException("Connection has timed out.");
reachability.Summary = "Port 22 on db.internal accepts connections.";
await vault.ConnectCommand.ExecuteAsync(null);
var tab = shell.Tabs.ShouldHaveSingleItem();
tab.Status.ShouldBe("Connection has timed out. Port 22 on db.internal accepts connections.");
reachability.Probed.ShouldHaveSingleItem().ShouldBe("db.internal:22");
}
/// <remarks>
/// Past <see cref="SshConnectionPhase.Reaching"/> the machine has offered a host key, so it is plainly
/// reachable and there is nothing a socket could add. A refused password that came back with "the network
/// is fine and something is listening" bolted on would be true, useless, and read as though the client
/// had not understood the question.
/// </remarks>
[Fact]
public async Task AConnectionRefusedAfterTheHostKey_IsNotProbed()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.FailAfter = SshConnectionPhase.Authenticating;
ssh.Failure = new InvalidOperationException("Permission denied (publickey,password).");
reachability.Summary = "this must not be appended";
await vault.ConnectCommand.ExecuteAsync(null);
shell.Tabs.ShouldHaveSingleItem().Status.ShouldBe("Permission denied (publickey,password).");
reachability.Probed.ShouldBeEmpty("the host key proved the machine was reached");
}
/// <remarks>
/// ◆ <b>The other end of the window, and the one that would state something false.</b> A failure with no
/// phase reported at all never dialled — the real factory throws out of <c>BuildConnectionInfo</c> on a
/// private key it cannot parse, before the socket is opened. Probing there finds a perfectly 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 replacing an accurate one.
/// </remarks>
[Fact]
public async Task AFailureBeforeAnythingWasDialled_IsNotProbed()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.FailAfter = null;
ssh.Failure = new InvalidOperationException("The private key could not be read.");
reachability.Summary = "this must not be appended";
await vault.ConnectCommand.ExecuteAsync(null);
shell.Tabs.ShouldHaveSingleItem().Status.ShouldBe("The private key could not be read.");
reachability.Probed.ShouldBeEmpty("nothing had been dialled, so there was nothing to ask about");
}
/// <remarks>
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
@@ -6383,6 +6577,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
Key: null,
KeyPath: null),
],
vaultId: null,
Token);
vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id");
@@ -6920,6 +7115,60 @@ public sealed class ShellFlowTests : IAsyncLifetime
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
}
/// <summary>
/// The desktop's Ctrl-click, and the card the board already had lit.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The board always has a selection, so "one card lit, nothing ticked" is the state this screen
/// opens in</b> — a fresh unlock lights the first host so that CONNECT has something under it, see
/// <c>VaultViewModel.SelectionAfterReload</c>. A Ctrl-click there used to start a set of one, leaving
/// the machine the user was looking at out of the count and out of everything Delete, Move to another
/// vault and Change group then ran over. It is the omission somebody could act on without noticing:
/// two cards are marked on screen and the bar says one.
/// </para>
/// <para>
/// The fold happens only into an empty set and never onto the card being clicked. The first is what
/// makes it a starting point rather than a card that keeps re-adding itself; the second is what keeps
/// every Ctrl-click visibly doing something.
/// </para>
/// </remarks>
[Fact]
public async Task ExtendingTheChoice_StartsTheSetWithTheCardTheBoardHasLit()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "staging");
vault.SelectedHost = Host(vault, "prod-db");
vault.IsChoosingHosts.ShouldBeFalse();
vault.ExtendHostChoiceCommand.Execute(Host(vault, "staging"));
vault.ChosenHostCount.ShouldBe(2, "the lit card is in the set the Ctrl-click began");
Host(vault, "prod-db").IsChosen.ShouldBeTrue("which is the half that used to be left out");
Host(vault, "staging").IsChosen.ShouldBeTrue();
vault.SelectedHost.ShouldBeSameAs(Host(vault, "prod-db"), "ticking a card is not selecting it");
vault.ExtendHostChoiceCommand.Execute(Host(vault, "prod-db"));
vault.ChosenHostCount.ShouldBe(1, "a set that already exists is not folded into again");
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
vault.ExtendHostChoiceCommand.Execute(Host(vault, "staging"));
vault.IsChoosingHosts.ShouldBeFalse("emptying the set leaves the mode, as a tap does");
// The lit card itself, from empty: it is ticked rather than folded in and toggled straight back out,
// which would be a gesture with no effect and no way to tell it had been heard.
vault.ExtendHostChoiceCommand.Execute(Host(vault, "prod-db"));
vault.ChosenHostCount.ShouldBe(1);
Host(vault, "prod-db").IsChosen.ShouldBeTrue();
}
/// <remarks>
/// The set is held as entity ids rather than as rows, and this is why: every row object in the list is
/// replaced on every synchronisation pass, so a set of rows would empty itself once a minute under
@@ -7135,6 +7384,97 @@ public sealed class ShellFlowTests : IAsyncLifetime
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
}
/// <summary>
/// The board's outer level, which is the vault, and its inner one, which is the group.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A group belongs to exactly one vault, and the board used to flatten that away.</b> Every group
/// of every readable vault was drawn in one run with a small grey vault name beside each heading, so two
/// vaults each holding a "Production" were two headings with the same word on them, told apart only by
/// reading the second line. Vaults are the headings now and groups sort the hosts inside one — see
/// <c>HostSectionViewModel.VaultHeading</c>.
/// </para>
/// <para>
/// The fold state is the half worth a test rather than a comment. There is one "No group" heading per
/// vault now, and every one of them names no group at all — keyed on <c>Guid.Empty</c> as they were,
/// folding one vault's unfiled hosts away folded every other vault's with it. See
/// <c>SidebarGroupHeader.CollapseKey</c>.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostBoard_PutsEachVaultsGroupsUnderItsOwnHeading()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = "Platform secrets";
await vaults.CreateVaultCommand.ExecuteAsync(null);
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
// The same group name in both vaults, which is the case the old board could not tell apart.
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = "Production";
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.NewGroupCommand.Execute(null);
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorLabel = "Production";
await vault.SaveGroupCommand.ExecuteAsync(null);
var sharedGroupId = vault.Groups.Single(group => group.VaultId == sharedVaultId).EntityId;
// One unfiled host in each vault, and one filed under the shared vault's own group.
await AddHostAsync(vault, "prod-db");
await AddHostInAsync(vault, sharedVaultId, "stage-web", groupId: null);
await AddHostInAsync(vault, sharedVaultId, "prod-web", sharedGroupId);
var sections = vault.HostSections.ToList();
var headings = sections.Where(section => section.StartsAVault).ToList();
headings.Count.ShouldBe(2, "one heading per vault, on the first section of each");
headings[1].VaultHeading.ShouldBe("PLATFORM SECRETS");
var opensTheShared = sections.IndexOf(headings[1]);
opensTheShared.ShouldBeGreaterThan(0, "the vault this session is standing in comes first");
sections.Take(opensTheShared).SelectMany(section => section.Hosts)
.ShouldAllBe(row => row.VaultId != sharedVaultId);
sections.Skip(opensTheShared).SelectMany(section => section.Hosts)
.ShouldAllBe(row => row.VaultId == sharedVaultId);
sections.Count(section => string.Equals(section.Header?.Label, "Production", StringComparison.Ordinal))
.ShouldBe(2, "each vault draws its own, rather than one heading for both");
sections.Where(section => section.Header is not null)
.ShouldAllBe(section => section.Header!.VaultBadge.Length == 0,
"the vault is named on the row above rather than beside every heading under it");
// ── The fold, which is per vault because the heading is ──
var unfiled = sections.Where(section => section.Header is { GroupId: null }).ToList();
unfiled.Count.ShouldBe(2, "each vault has unfiled hosts of its own");
vault.ToggleGroupCommand.Execute(unfiled[0].Header);
var folded = vault.HostSections.Where(section => section.Header is { GroupId: null }).ToList();
folded[0].Header!.IsExpanded.ShouldBeFalse();
folded[0].Hosts.ShouldBeEmpty("a folded section draws its heading and no cards");
folded[1].Header!.IsExpanded.ShouldBeTrue("one vault's unfiled hosts fold on their own");
}
/// <remarks>
/// The other refusal at the same door, and the one that needs two keychains to raise: a group is an
/// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault
@@ -10010,6 +10350,33 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
/// <summary>Adds a host to a named vault, optionally filed under one of that vault's groups.</summary>
/// <remarks>
/// The editor's own vault picker rather than the keychain screen's standing preference, which is the
/// order the two are meant to be used in: where a host goes is a field of the host in front of you. The
/// vault is set first because the group choices are that vault's — see
/// <c>VaultViewModel.EditorGroupChoices</c>.
/// </remarks>
private static async Task AddHostInAsync(
VaultViewModel vault,
Guid vaultId,
string label,
Guid? groupId)
{
vault.NewHostCommand.Execute(null);
vault.EditorSelectedVault =
vault.EditorVaultChoices.Single(choice => choice.VaultId == vaultId);
vault.EditorSelectedGroup =
vault.EditorGroupChoices.Single(choice => choice.EntityId == groupId);
vault.EditorLabel = label;
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
}
private static async Task AddHostAsync(VaultViewModel vault, string label)
{
vault.NewHostCommand.Execute(null);
@@ -0,0 +1,201 @@
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// One desktop instance per profile, and what the second launch does instead of starting.
/// </summary>
/// <remarks>
/// <para>
/// Both halves are real here: a real lock file in a real temporary profile, and a real named pipe. Neither
/// is worth faking — the whole claim being made is about what the operating system does with an exclusive
/// handle and a pipe name, and a fake of either would be a test of the fake.
/// </para>
/// <para>
/// What is <em>not</em> reachable from here is a second process. Every test below runs in one, so the lock
/// is proved against a second <em>handle</em> rather than a second executable, and the window that comes
/// forward is a delegate rather than a window. <c>docs/manual-checks.md</c> 16.11 is the other side of that
/// line.
/// </para>
/// </remarks>
public sealed class SingleInstanceTests : IDisposable
{
/// <summary>
/// A profile of its own per test, which is what makes these safe to run beside a real installation.
/// </summary>
/// <remarks>
/// The pipe name is derived from this directory — see <c>SingleInstance.PipeNameFor</c> — so a unique
/// directory is also a unique pipe. Without that, a test here would ring the doorbell of whatever
/// DodoSSH the developer happens to have running.
/// </remarks>
private readonly ClientPaths paths =
new(Path.Combine(Path.GetTempPath(), $"dodossh-instance-{Guid.CreateVersion7():N}"));
/// <summary>
/// No patience at all, which is what every test here wants.
/// </summary>
/// <remarks>
/// The shipped three seconds exist for a lock held by a process on its way out — an update relaunching
/// itself, above all — and nothing here is on its way anywhere. Passing zero takes the give-up decision
/// on the first attempt, so a refusal costs a millisecond rather than three seconds of the suite.
/// </remarks>
private static readonly TimeSpan Impatient = TimeSpan.Zero;
/// <summary>
/// How long a doorbell is given to ring before the test says it never will.
/// </summary>
/// <remarks>
/// ◆ <b>A bound at all, which is the lesson rather than the number.</b> These two awaits were written
/// against <c>TestContext.Current.CancellationToken</c> alone, which carries no timeout — so the run
/// where a ring was genuinely lost did not fail, it <em>hung</em>, and took the whole CI job with it for
/// as long as anybody let it. Twenty seconds is a hundred times what a local pipe needs and a fifth of
/// what it costs to notice; a bound that is never reached costs nothing and a missing one costs the
/// build.
/// </remarks>
private static readonly TimeSpan LongEnoughForAPipe = TimeSpan.FromSeconds(20);
/// <inheritdoc />
public void Dispose()
{
if (Directory.Exists(paths.DataDirectory))
{
Directory.Delete(paths.DataDirectory, recursive: true);
}
}
[Fact]
public void TheFirstLaunchClaimsTheProfile()
{
using var claim = SingleInstance.Claim(paths, Impatient);
claim.ShouldNotBeNull();
File.Exists(paths.InstanceLockFile).ShouldBeTrue("the claim is a handle on a real file");
}
/// <remarks>
/// The point of the whole exercise. Nobody is listening in this test, so the second claim takes the
/// give-up path rather than the ask-and-leave one — which is why it is handed no patience at all.
/// </remarks>
[Fact]
public void ASecondLaunchIsRefused()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
SingleInstance.Claim(paths, Impatient).ShouldBeNull();
}
/// <remarks>
/// ◆ There is no such thing as a stale lock here, and this is what says so. The handle is closed by the
/// runtime when it is disposed and by the kernel when the process ends however it ends — which is the
/// reason this is a file rather than a named mutex, whose abandonment is a state to be handled.
/// </remarks>
[Fact]
public void TheProfileIsClaimableAgainOnceTheFirstInstanceLetsGo()
{
var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
first.Dispose();
using var second = SingleInstance.Claim(paths, Impatient);
second.ShouldNotBeNull("the lock went with the handle");
}
/// <remarks>
/// The other half: a refused launch is not a silent one. Somebody who double-clicks the icon and sees
/// nothing has been told the application is broken, so the running instance is asked to come forward.
/// </remarks>
[Fact]
public async Task ARefusedLaunchAsksTheRunningInstanceToComeForward()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
var asked = new TaskCompletionSource();
await using var doorbell = SingleInstance.Listen(paths, () => asked.TrySetResult());
SingleInstance.Claim(paths, Impatient).ShouldBeNull("the profile is taken");
// Awaited rather than polled: the callback comes off a thread pool thread. Bounded explicitly,
// because the test's own token is not — see LongEnoughForAPipe.
await asked.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
}
/// <remarks>
/// <para>
/// A third and a fourth launch work as well as the second, and this is the test that caught the doorbell
/// getting that wrong on Linux and only on Linux. A fresh <c>NamedPipeServerStream</c> per connection
/// closes the listening socket between callers there, so the second ring was accepted by nobody while
/// the launch that made it was told it had succeeded. See <c>SingleInstance.Doorbell</c>.
/// </para>
/// <para>
/// Two rings back to back rather than one and a pause, because the gap is the whole defect: a test that
/// waited for the first to be answered before making the second would pass against the broken version.
/// </para>
/// </remarks>
[Fact]
public async Task TheDoorbellAnswersMoreThanOnce()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
var rings = 0;
var twice = new TaskCompletionSource();
await using var doorbell = SingleInstance.Listen(
paths,
() =>
{
if (Interlocked.Increment(ref rings) == 2)
{
twice.TrySetResult();
}
});
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
await twice.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
}
/// <remarks>
/// What the give-up path turns on. A lock held by something that is not listening — a process still
/// starting, or one on its way out — answers no, and that is what makes <c>Claim</c> wait and try the
/// lock again rather than concluding anything from the silence.
/// </remarks>
[Fact]
public void AskingWithNobodyListening_SaysSo()
{
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeFalse();
}
/// <remarks>
/// Two profiles, because a nightly and a release build are installed side by side on purpose and must
/// not exclude each other — ADR 0013. The lock file is inside the profile and the pipe name is derived
/// from it, so both halves are scoped by the one decision <c>ClientPaths.ForChannel</c> already made.
/// </remarks>
[Fact]
public void TwoProfilesDoNotExcludeEachOther()
{
var other = new ClientPaths(paths.DataDirectory + ".Nightly");
try
{
using var release = SingleInstance.Claim(paths, Impatient);
using var nightly = SingleInstance.Claim(other, Impatient);
release.ShouldNotBeNull();
nightly.ShouldNotBeNull("a second profile is a second application");
}
finally
{
if (Directory.Exists(other.DataDirectory))
{
Directory.Delete(other.DataDirectory, recursive: true);
}
}
}
}
@@ -390,8 +390,14 @@ public sealed class UpdateFlowTests : IDisposable
updates.State.ShouldBe(UpdateState.Ready);
}
/// <remarks>
/// Asks, and does not fetch again. The distinction is the whole of the fix for a client that installed
/// two updates to move one step: the old guard skipped the <em>pass</em> once anything was ready, so the
/// banner was pinned to whatever build the process happened to see first. Not re-fetching is still the
/// rule; it is enforced by comparing versions now rather than by refusing to look.
/// </remarks>
[Fact]
public async Task APassWhileOneIsAlreadyReady_DoesNotAskAgain()
public async Task APassWhileOneIsAlreadyReady_AsksAgainAndDoesNotFetchTwice()
{
channel.Available = new AvailableUpdate("1.3.0");
@@ -400,7 +406,181 @@ public sealed class UpdateFlowTests : IDisposable
await updates.CheckOnceAsync(Token);
await updates.CheckOnceAsync(Token);
channel.Checks.ShouldBe(1);
channel.Checks.ShouldBe(2);
channel.Downloads.ShouldBe(1, "the second pass found the build it had already fetched");
updates.State.ShouldBe(UpdateState.Ready);
updates.ReadyVersion.ShouldBe("1.3.0");
}
/// <remarks>
/// ◆ The regression this whole change is about, in the case the timer can reach. A rolling nightly
/// channel keeps exactly one build, so a client left running does not accumulate a queue of updates — it
/// is offered whichever one the feed holds, and the pending build has to be replaced when that moves on.
/// </remarks>
[Fact]
public async Task APassWhileOneIsAlreadyReady_ReplacesItWhenTheFeedHasMovedOn()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
updates.ReadyVersion.ShouldBe("1.3.0");
channel.Available = new AvailableUpdate("1.4.0");
await updates.CheckOnceAsync(Token);
channel.Downloads.ShouldBe(2);
updates.State.ShouldBe(UpdateState.Ready);
updates.ReadyVersion.ShouldBe("1.4.0");
}
/// <remarks>
/// A build superseded after LATER was pressed brings the banner back, because LATER was an answer about
/// a version and this is a different one.
/// </remarks>
[Fact]
public async Task ASupersededOffer_IsShownAgainEvenIfTheOldOneWasDismissed()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
updates.DismissBannerCommand.Execute(null);
updates.IsBannerShowing.ShouldBeFalse();
channel.Available = new AvailableUpdate("1.4.0");
await updates.CheckOnceAsync(Token);
updates.IsBannerShowing.ShouldBeTrue();
updates.ReadyHeadline.ShouldContain("1.4.0");
}
/// <remarks>
/// ◆ The half the timer cannot reach, and the one a person actually hit: a build published between the
/// last pass and the press. Without this the press installs a build that was already superseded, and the
/// next launch offers the newer one straight away — two downloads, two installs and two restarts to move
/// one step.
/// </remarks>
[Fact]
public async Task Restarting_TakesOneLastLookAndInstallsWhatItFinds()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
// Published while the banner was up, which on a rolling channel replaces the release rather than
// adding to it — so the feed now holds only this one.
channel.Available = new AvailableUpdate("1.4.0");
await updates.RestartNowCommand.ExecuteAsync(null);
channel.Restarts.ShouldBe(1);
channel.RestartedWith!.Version.ShouldBe("1.4.0");
restarts.ShouldBe(1, "one restart, not one per build");
}
/// <remarks>
/// The ordinary case, and what it costs: one question and no download. A press that re-fetched what was
/// already on disk would have made the fix worse than the bug.
/// </remarks>
[Fact]
public async Task Restarting_AsksOnceMoreAndFetchesNothingWhenNothingIsNewer()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
await updates.RestartNowCommand.ExecuteAsync(null);
channel.Checks.ShouldBe(2);
channel.Downloads.ShouldBe(1);
channel.RestartedWith!.Version.ShouldBe("1.3.0");
}
/// <remarks>
/// The look is a courtesy and never a gate. Somebody pressing RESTART NOW on a train has a build on disk
/// that is newer than the one running, and a forge that cannot be reached must not stand between them.
/// </remarks>
[Fact]
public async Task RestartingWhenTheLastLookFails_StillInstallsWhatWasAlreadyFetched()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
channel.CheckFailure = new HttpRequestException("no such host");
await updates.RestartNowCommand.ExecuteAsync(null);
channel.Restarts.ShouldBe(1);
channel.RestartedWith!.Version.ShouldBe("1.3.0");
updates.State.ShouldNotBe(UpdateState.Failed, "nothing the user asked for failed");
}
/// <inheritdoc cref="RestartingWhenTheLastLookFails_StillInstallsWhatWasAlreadyFetched" />
/// <remarks>
/// The same rule one step further in: the newer build was found and then could not be fetched. What is
/// on disk is untouched, so that is what gets installed.
/// </remarks>
[Fact]
public async Task RestartingWhenTheNewerFetchFails_StillInstallsWhatWasAlreadyFetched()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
channel.Available = new AvailableUpdate("1.4.0");
channel.DownloadFailure = new HttpRequestException("the connection was reset");
await updates.RestartNowCommand.ExecuteAsync(null);
channel.Restarts.ShouldBe(1);
channel.RestartedWith!.Version.ShouldBe("1.3.0");
}
/// <remarks>
/// Everything the banner draws keys off <c>IsReady</c>, and the last look moves the state off it — so
/// without <c>IsRestarting</c> the strip vanished the moment RESTART NOW was pressed and left nothing on
/// screen saying why, for as long as a package takes to fetch.
/// </remarks>
[Fact]
public async Task TheBannerStaysUpWhileARestartFetchesANewerBuild()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
channel.Available = new AvailableUpdate("1.4.0");
channel.HoldDownload = new TaskCompletionSource();
var pressed = updates.RestartNowCommand.ExecuteAsync(null);
// The gate is what makes the intermediate state observable; without it the fetch is over before
// anything could look at it.
while (updates.State is not UpdateState.Downloading)
{
await Task.Yield();
}
updates.IsBannerShowing.ShouldBeTrue();
updates.ReadyHeadline.ShouldContain("before restarting");
channel.HoldDownload.SetResult();
await pressed;
channel.RestartedWith!.Version.ShouldBe("1.4.0");
}
/// <remarks>
@@ -1,9 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The ssh_config reader, against strings. No disk, because the parser takes its include reader as a
Both readers, each against a stand-in for the world it reads.
The ssh_config one runs against strings. No disk, because the parser takes its include reader as a
parameter — which is what lets Include, the one directive whose behaviour is a file-system behaviour,
be covered by ordinary unit tests.
The Hetzner one runs against a stubbed HttpMessageHandler, which is the same trade: no account, no token
and no network, and every question about what the client decides from what Hetzner said still answerable.
What that leaves for a person is in docs/manual-checks.md, 2.11a onwards.
-->
<ItemGroup>
@@ -0,0 +1,416 @@
using System.Net;
using System.Text;
using DodoSSH.Client.Import;
namespace DodoSSH.Client.Import.Tests;
/// <summary>
/// Reading a Hetzner Cloud project, against a stubbed transport.
/// </summary>
/// <remarks>
/// <para>
/// No socket, for the reason <c>LinuxUpdateChannelTests</c> gives: every question here is what this client
/// decides from what Hetzner said, and a real request would add an account, a token and a network to the
/// assertions without adding anything to what is being checked.
/// </para>
/// <para>
/// The bodies are real shapes rather than minimal ones. An importer's whole value is agreeing with the
/// source about what it means — the IPv6 field holding a network rather than an address, and a server that
/// has no IPv4 at all, are both ordinary in a Hetzner project and both are exactly where an importer that
/// was nearly right would produce bookmarks that nearly connect.
/// </para>
/// </remarks>
public sealed class HetznerCloudClientTests
{
private const string Token = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
private static CancellationToken Cancellation => TestContext.Current.CancellationToken;
[Fact]
public async Task APageOfServers_ArrivesWithWhatTheProjectSaidAboutThem()
{
var import = await ReadAsync(OnePage(OneServer));
var server = import.Servers.ShouldHaveSingleItem();
server.Id.ShouldBe(42);
server.Name.ShouldBe("web-01");
server.Status.ShouldBe("running");
server.PublicIPv4.ShouldBe("203.0.113.10");
server.ServerType.ShouldBe("cx22");
server.Location.ShouldBe("fsn1 · Falkenstein, DE");
server.Image.ShouldBe("ubuntu-24.04");
server.Labels.ShouldBe(["env=prod", "role=web"]);
import.Warnings.ShouldBeEmpty();
}
/// <remarks>
/// The token goes in an <c>Authorization: Bearer</c> header and nowhere else — never a query parameter,
/// which is the shape that ends up in somebody's proxy log.
/// </remarks>
[Fact]
public async Task TheTokenTravelsAsABearerHeader()
{
var stub = new StubHetzner([OnePage(OneServer)]);
await new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation);
var request = stub.Requests.ShouldHaveSingleItem();
request.Authorization.ShouldBe($"Bearer {Token}");
request.Uri.ShouldNotContain(Token);
request.Uri.ShouldContain("per_page=50");
}
/// <summary>
/// Every page is followed, which is what keeps a short list from looking like a complete one.
/// </summary>
/// <remarks>
/// Fifty-one servers in a project is enough to meet this, and an importer that showed the first fifty
/// would present a list nobody has any reason to doubt.
/// </remarks>
[Fact]
public async Task EveryPageIsFollowed()
{
var stub = new StubHetzner([
Page(nextPage: 2, """{ "id": 1, "name": "web-01" }"""),
Page(nextPage: 3, """{ "id": 2, "name": "web-02" }"""),
Page(nextPage: null, """{ "id": 3, "name": "web-03" }"""),
]);
var import = await new HetznerCloudClient(new HttpClient(stub))
.ListServersAsync(Token, Cancellation);
import.Servers.Select(server => server.Name).ShouldBe(["web-01", "web-02", "web-03"]);
stub.Requests.Select(request => request.Uri).ShouldAllBe(uri => uri.Contains("page="));
stub.Requests.Count.ShouldBe(3);
}
/// <remarks>
/// A refused token is the failure this wizard meets most, because the value is shown once in the Hetzner
/// console and a half-copied one fails exactly like a wrong one. So the message says where tokens come
/// from, and <c>IsTokenProblem</c> is what sends the wizard back to the step that has the box.
/// </remarks>
[Fact]
public async Task ARefusedToken_SaysWhereTokensComeFromAndIsMarkedAsTheTokensFault()
{
var stub = new StubHetzner([
new StubResponse(
HttpStatusCode.Unauthorized,
"""{ "error": { "code": "unauthorized", "message": "unable to authenticate" } }"""),
]);
var failure = await Should.ThrowAsync<HetznerCloudException>(
() => new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation));
failure.IsTokenProblem.ShouldBeTrue();
failure.Code.ShouldBe("unauthorized");
failure.Message.ShouldContain("API tokens");
failure.Message.ShouldContain("unable to authenticate", Case.Insensitive);
}
/// <remarks>
/// The other half of that split: Hetzner having a bad minute is not a reason to send somebody back to
/// re-type a token that was fine.
/// </remarks>
[Fact]
public async Task AFaultAtHetznersEnd_IsNotTheTokensFault()
{
var stub = new StubHetzner([new StubResponse(HttpStatusCode.BadGateway, "<html>nope</html>")]);
var failure = await Should.ThrowAsync<HetznerCloudException>(
() => new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation));
failure.IsTokenProblem.ShouldBeFalse();
failure.Message.ShouldContain("502");
}
/// <remarks>
/// Refused before anything is sent, because a header value carrying a line break throws from inside
/// <c>HttpClient</c> — a stack trace where the honest answer is a sentence.
/// </remarks>
[Fact]
public async Task ATokenWithALineBreakInTheMiddle_IsRefusedWithoutAskingHetzner()
{
var stub = new StubHetzner([OnePage(OneServer)]);
var failure = await Should.ThrowAsync<HetznerCloudException>(
() => new HetznerCloudClient(new HttpClient(stub))
.ListServersAsync("abcdef\n0123456789", Cancellation));
failure.Message.ShouldContain("64 letters and digits");
stub.Requests.ShouldBeEmpty();
}
/// <remarks>Pasting out of the console brings a trailing newline often enough to be worth handling.</remarks>
[Fact]
public async Task ATokenPastedWithSurroundingWhitespace_IsTrimmedRatherThanRefused()
{
var stub = new StubHetzner([OnePage(OneServer)]);
await new HetznerCloudClient(new HttpClient(stub)).ListServersAsync($" {Token}\n", Cancellation);
stub.Requests.ShouldHaveSingleItem().Authorization.ShouldBe($"Bearer {Token}");
}
/// <summary>
/// A server with no IPv4 at all, which is a whole Hetzner price tier rather than an edge case.
/// </summary>
/// <remarks>
/// Two things are being checked and the second is the one that matters. The address is the first in the
/// routed /64, because what the API reports is a network and nothing can dial a network. And it carries
/// a warning, because that address is a convention of Hetzner's own images rather than a fact the API
/// stated — an import that silently guessed would produce a bookmark that fails to connect with no
/// indication of why.
/// </remarks>
[Fact]
public async Task AServerWithNoIPv4_DialsTheFirstAddressOfItsSixtyFourAndSaysSo()
{
var import = await ReadAsync(OnePage("""
{
"id": 7,
"name": "v6-only",
"status": "running",
"public_net": {
"ipv4": null,
"ipv6": { "ip": "2a01:4f8:1c17:d00::/64" }
}
}
"""));
var server = import.Servers.ShouldHaveSingleItem();
server.HasPublicIPv4.ShouldBeFalse();
server.FirstIPv6Address.ShouldBe("2a01:4f8:1c17:d00::1");
var address = server.Resolve(HetznerAddressKind.PublicIPv4).ShouldNotBeNull();
address.Value.ShouldBe("2a01:4f8:1c17:d00::1");
address.Warning.ShouldNotBeNull().ShouldContain("No public IPv4");
}
/// <remarks>
/// The v6 choice warns even where it is the one that was asked for, on the same reasoning: the address
/// is inferred from a network either way.
/// </remarks>
[Fact]
public async Task ChoosingIPv6_StillSaysTheAddressWasInferredFromANetwork()
{
var import = await ReadAsync(OnePage(OneServer));
var address = import.Servers[0].Resolve(HetznerAddressKind.PublicIPv6).ShouldNotBeNull();
address.Value.ShouldBe("2a01:4f8:1c17:d00::1");
address.Warning.ShouldNotBeNull().ShouldContain("2a01:4f8:1c17:d00::/64");
}
/// <summary>
/// The private choice does not fall back, and that asymmetry is the point.
/// </summary>
/// <remarks>
/// Falling back between v4 and v6 reaches the same machine over the same internet. 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 network is simply not importable under that choice.
/// </remarks>
[Fact]
public async Task AServerOnNoPrivateNetwork_HasNoPrivateAddressToOffer()
{
var import = await ReadAsync(OnePage(OneServer));
import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldBeNull();
}
[Fact]
public async Task AServerOnAPrivateNetwork_DialsItWithoutAWarning()
{
var import = await ReadAsync(OnePage("""
{
"id": 9,
"name": "db-01",
"private_net": [ { "ip": "10.0.0.4" } ]
}
"""));
var address = import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldNotBeNull();
address.Value.ShouldBe("10.0.0.4");
address.Warning.ShouldBeNull();
}
/// <remarks>
/// Two private networks is a real shape and the first one is a choice this makes rather than a fact, so
/// it is said out loud — the same rule the IPv6 address follows.
/// </remarks>
[Fact]
public async Task AServerOnTwoPrivateNetworks_TakesTheFirstAndSaysThereWereTwo()
{
var import = await ReadAsync(OnePage("""
{
"id": 9,
"name": "db-01",
"private_net": [ { "ip": "10.0.0.4" }, { "ip": "10.1.0.4" } ]
}
"""));
var address = import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldNotBeNull();
address.Value.ShouldBe("10.0.0.4");
address.Warning.ShouldNotBeNull().ShouldContain("2 private networks");
}
/// <summary>What is stored, and what is deliberately not.</summary>
/// <remarks>
/// The note keeps what does not go stale — the id, the plan, where it runs, what it was built from. The
/// power state is not in it: a note saying "running" about a machine somebody switched off last March is
/// worse than no note, so status is a column on the import screen and never a stored fact.
/// </remarks>
[Fact]
public async Task WhatIsStored_CarriesTheProjectsContextAndNotItsPowerState()
{
var import = await ReadAsync(OnePage(OneServer));
var server = import.Servers[0];
var host = server.ToSecret(
server.Resolve(HetznerAddressKind.PublicIPv4).ShouldNotBeNull(),
"root",
22);
host.Label.ShouldBe("web-01");
host.Hostname.ShouldBe("203.0.113.10");
host.Username.ShouldBe("root");
host.Port.ShouldBe(22);
host.SshKeyId.ShouldBeNull("the private half of a key was never Hetzner's to hand over");
var notes = host.Notes.ShouldNotBeNull();
notes.ShouldContain("server 42");
notes.ShouldContain("cx22");
notes.ShouldContain("fsn1 · Falkenstein, DE");
notes.ShouldContain("ubuntu-24.04");
notes.ShouldContain("env=prod");
notes.ShouldNotContain("running");
host.TryValidate(out _).ShouldBeTrue();
}
/// <remarks>
/// A blank login user stores none rather than storing an empty one, which is what the ssh_config import
/// does for an entry that named no <c>User</c>.
/// </remarks>
[Fact]
public async Task ABlankLoginUser_StoresNoUsername()
{
var import = await ReadAsync(OnePage(OneServer));
var server = import.Servers[0];
server.ToSecret(server.Resolve(HetznerAddressKind.PublicIPv4)!, " ", 22).Username.ShouldBeNull();
}
/// <remarks>
/// A server with no name would arrive as a bookmark called nothing, which <c>HostSecret.TryValidate</c>
/// refuses outright — so it is named for its id and the project-level warning says one was.
/// </remarks>
[Fact]
public async Task AServerWithNoName_IsOfferedUnderItsIdAndWarnedAbout()
{
var import = await ReadAsync(OnePage("""{ "id": 512, "status": "off" }"""));
import.Servers.ShouldHaveSingleItem().Name.ShouldBe("server-512");
import.Warnings.ShouldHaveSingleItem().ShouldContain("server-512");
}
/// <remarks>
/// Everything but the id is optional in practice — a server built from a snapshot has no image, and one
/// mid-creation has no datacentre yet. None of that is a reason to refuse the whole page.
/// </remarks>
[Fact]
public async Task AServerTheApiDescribedSparsely_StillImports()
{
var import = await ReadAsync(OnePage("""{ "id": 3, "name": "fresh", "status": "initializing" }"""));
var server = import.Servers.ShouldHaveSingleItem();
server.ServerType.ShouldBeNull();
server.Location.ShouldBeNull();
server.Image.ShouldBeNull();
server.Labels.ShouldBeEmpty();
server.Resolve(HetznerAddressKind.PublicIPv4).ShouldBeNull("it has no address of any kind yet");
}
// ---- Helpers ----
/// <summary>A server carrying everything the API can say about one, used wherever the shape is not the point.</summary>
private const string OneServer = """
{
"id": 42,
"name": "web-01",
"status": "running",
"public_net": {
"ipv4": { "id": 1, "ip": "203.0.113.10", "blocked": false },
"ipv6": { "id": 2, "ip": "2a01:4f8:1c17:d00::/64", "blocked": false }
},
"private_net": [],
"server_type": { "id": 22, "name": "cx22", "cores": 2 },
"datacenter": {
"id": 4,
"name": "fsn1-dc14",
"location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" }
},
"image": { "id": 100, "name": "ubuntu-24.04", "os_flavor": "ubuntu" },
"labels": { "role": "web", "env": "prod" }
}
""";
private static Task<HetznerImport> ReadAsync(StubResponse page) =>
new HetznerCloudClient(new HttpClient(new StubHetzner([page]))).ListServersAsync(Token, Cancellation);
private static StubResponse OnePage(string server) => Page(nextPage: null, server);
private static StubResponse Page(int? nextPage, string server) =>
new(
HttpStatusCode.OK,
$$"""
{
"servers": [ {{server}} ],
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": {{Next(nextPage)}} } }
}
""");
private static string Next(int? page) =>
page is { } value ? value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "null";
private sealed record StubResponse(HttpStatusCode Status, string Body);
private sealed record SeenRequest(string Uri, string? Authorization);
/// <summary>
/// Answers each request with the next scripted response, and remembers what was asked.
/// </summary>
/// <remarks>
/// Scripted in order rather than keyed by URL, because the thing being checked in the paging test is the
/// sequence: a client that asked for page one three times would pass a lookup by address and produce the
/// same list three times over.
/// </remarks>
private sealed class StubHetzner(IReadOnlyList<StubResponse> responses) : HttpMessageHandler
{
private int served;
internal List<SeenRequest> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(new SeenRequest(
request.RequestUri!.AbsoluteUri,
request.Headers.Authorization?.ToString()));
var scripted = responses[Math.Min(served++, responses.Count - 1)];
return Task.FromResult(new HttpResponseMessage(scripted.Status)
{
Content = new StringContent(scripted.Body, Encoding.UTF8, "application/json"),
});
}
}
}
@@ -0,0 +1,171 @@
using System.Net;
using System.Net.Sockets;
namespace DodoSSH.Client.Ssh.Tests;
/// <summary>
/// The probe that runs after a connection has failed, and the four answers it can come back with.
/// </summary>
/// <remarks>
/// <para>
/// Real sockets on loopback rather than an abstraction over them, because the thing under test <em>is</em>
/// what a socket does: the whole reason this type exists is that a refusal and a silence are
/// indistinguishable in the exception SSH.NET throws and completely distinguishable to a TCP connect. A fake
/// socket would be this suite asserting that the mapping table maps, and would have passed just as happily
/// against the bug that prompted the work.
/// </para>
/// <para>
/// No container and no sshd. Nothing here speaks SSH — the probe deliberately does not — so the fixture is a
/// listener that accepts, a port nothing is on, and a name that does not resolve.
/// </para>
/// </remarks>
public sealed class SshReachabilityProbeTests
{
/// <remarks>
/// Short, because two of these cases prove a negative by waiting. The production budget is six seconds —
/// see <see cref="SshReachabilityProbe.Budget"/> — and nothing about the branch under test changes with
/// the number, so the suite buys the same coverage for a fraction of the wall clock.
/// </remarks>
private static readonly TimeSpan ShortBudget = TimeSpan.FromSeconds(2);
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <remarks>
/// The case the whole type was written for. SSH.NET reports this and <see cref="NothingListening"/> with
/// the identical "Connection has timed out." — so if this comes back as anything but
/// <see cref="SshReach.Open"/>, the probe is adding nothing the exception did not already say.
/// </remarks>
[Fact]
public async Task APortThatAcceptsIsOpen_WhichIsWhatTellsAWedgedSshdFromABrokenNetwork()
{
using var listener = Accepting();
var report = await new SshReachabilityProbe(ShortBudget)
.ProbeAsync("127.0.0.1", PortOf(listener), Token);
report.Reach.ShouldBe(SshReach.Open);
report.Address.ShouldBe("127.0.0.1");
report.Summary.ShouldContain("handshake");
// The half a user acts on: nothing here may suggest the network is at fault, because it demonstrably
// is not — a socket just opened over it.
report.Summary.ShouldNotContain("firewall");
}
/// <remarks>
/// <para>
/// A port with nothing on it. The distinction from <see cref="NothingAnswers"/> is the entire value of
/// the finding: refused means the machine is up.
/// </para>
/// <para>
/// ◆ <b>The full budget, and this test is why the production one is six seconds.</b> A refusal is not
/// immediate on Windows — the stack retries the SYN, and a closed loopback port took ~2s to answer when
/// this was written. Run against <see cref="ShortBudget"/> this case comes back
/// <see cref="SshReach.NoAnswer"/>, which is the probe telling somebody a machine that is up and
/// refusing is behind a firewall. Left at the real budget on purpose: it is the one case whose
/// correctness depends on the number, so it is the one that should fail if the number is lowered.
/// </para>
/// </remarks>
[Fact]
public async Task NothingListening()
{
var report = await new SshReachabilityProbe(SshReachabilityProbe.Budget)
.ProbeAsync("127.0.0.1", UnusedPort(), Token);
report.Reach.ShouldBe(SshReach.Refused);
report.Summary.ShouldContain("sshd is not running");
}
/// <remarks>
/// <para>
/// 192.0.2.1 is TEST-NET-1 from RFC 5737: reserved for documentation, routed nowhere, and guaranteed not
/// to be some real machine that answers and makes this flap. The packets go out and nothing comes back,
/// which is exactly the shape of a firewall that drops rather than rejects.
/// </para>
/// <para>
/// The elapsed assertion is the one that matters beside the enum. Silence is only evidence if the probe
/// actually waited for it, and a bug that returned <see cref="SshReach.NoAnswer"/> instantly — a socket
/// erroring for some unrelated reason — would otherwise pass this while telling the user something false
/// about their firewall.
/// </para>
/// </remarks>
[Fact]
public async Task NothingAnswers()
{
var report = await new SshReachabilityProbe(ShortBudget).ProbeAsync("192.0.2.1", 22, Token);
report.Reach.ShouldBe(SshReach.NoAnswer);
report.Elapsed.ShouldBeGreaterThan(TimeSpan.FromSeconds(1));
report.Summary.ShouldContain("firewall");
}
/// <remarks>
/// <c>.invalid</c> is reserved by RFC 2606 precisely so that a test may rely on it never resolving, on
/// any network, including one whose resolver answers every NXDOMAIN with a search page.
/// </remarks>
[Fact]
public async Task ANameThatDoesNotResolve()
{
var report = await new SshReachabilityProbe(ShortBudget)
.ProbeAsync("dodossh-no-such-host.invalid", 22, Token);
report.Reach.ShouldBe(SshReach.Unresolved);
report.Address.ShouldBeNull("there is no address, and inventing one would be the whole failure");
report.Summary.ShouldContain("does not resolve");
}
/// <remarks>
/// An address typed in is not looked up, and the report must not read as though it had been. The
/// assertion is on the sentence rather than on a call count: what would be wrong is the user being shown
/// "127.0.0.1 (127.0.0.1)", which is what a probe that resolved a literal and then printed both produces.
/// </remarks>
[Fact]
public async Task AnAddressIsNotPrintedTwice()
{
using var listener = Accepting();
var report = await new SshReachabilityProbe(ShortBudget)
.ProbeAsync("127.0.0.1", PortOf(listener), Token);
report.Summary.ShouldNotContain("(127.0.0.1)");
}
/// <remarks>
/// The caller is in a failure path with nothing to do with a second exception — see
/// <see cref="ISshReachabilityProbe.ProbeAsync"/> — so every branch above returns a report. This is the
/// one input that is a caller's mistake rather than a broken host, and it is the one that still throws.
/// </remarks>
[Fact]
public async Task AnEmptyHostIsTheCallersBug_NotAFinding()
{
await Should.ThrowAsync<ArgumentException>(
async () => await new SshReachabilityProbe(ShortBudget).ProbeAsync(" ", 22, Token));
}
private static TcpListener Accepting()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return listener;
}
private static int PortOf(TcpListener listener) => ((IPEndPoint)listener.LocalEndpoint).Port;
/// <summary>A port that was free a moment ago, which is the closest thing to a guarantee there is.</summary>
/// <remarks>
/// Bound and released rather than picked out of the air: the OS hands out a port it knows is unused, and
/// the window in which something else could take it is far smaller than the odds of a hard-coded number
/// colliding with whatever the machine running this happens to have open.
/// </remarks>
private static int UnusedPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = PortOf(listener);
listener.Stop();
return port;
}
}
@@ -5,17 +5,36 @@
It fakes exactly what the page's transport touches and nothing else: a WebSocket whose every step is
driven from the test, a clock that only moves when a test moves it, and the two DOM objects the page
reads at load. There is no Terminal, no fit addon and no WebGL here, because nothing on the reconnection
path builds a pane — a test that opens a session will have to add them, and should, rather than this
file guessing now at what such a test would want.
reads at load.
◆ AND, SINCE RendererClipboardTests, A PANE.
Which the reconnection tests never needed and this file used to say so, inviting whoever first opened a
session to add the Terminal, the fit addon and the WebGL addon rather than guessing at them in advance.
They are below, and they are stubs with a memory: a fake terminal records what was pasted into it, what
was typed into it and what it was asked to focus, because those three are the whole of what the page does
to a pane and therefore the whole of what a test can ask about. The selection is settable from a test,
since a selection is made with a mouse in a canvas and there is no canvas here.
What a fake terminal is NOT is xterm. It cannot say whether paste() brackets its argument or whether
getSelection() trims a row's right padding — that is xterm's behaviour, exercised by xterm's own suite and
by docs/manual-checks.md 1.11 here. What these tests can prove is which of the two the page calls, with
what, and addressed to which session, and that is where every mistake in this area has actually been.
◆ NOTHING HERE MAY DECLARE A NAME terminal.js ALSO DECLARES.
Both files are evaluated as scripts into the same global scope, and the page's own `const root` against
a `var root` here is a SyntaxError before a line of either runs. That is why the page's two elements are
Both files are evaluated as scripts into the same global scope, and a collision goes one of two ways.
Against one of the page's `const`s — its own `root`, say — it is a SyntaxError before a line of either
runs, which is the good outcome: it is impossible to miss. That is why the page's two elements are
`pageRoot` and `pageBanner` below and reached through getElementById, which is how the page reaches them
anyway.
Against one of the page's *functions* there is no error at all. The page is evaluated second, so its
declaration silently replaces this file's and every test calling that name gets the page's function
instead — which is how `frame()` here, meant to read a sent frame back, quietly returned the ArrayBuffer
that terminal.js's own frame() builds instead, and failed six tests on a type error a long way from the
cause. Hence `sentFrames`/`sentFrame` below: prefixed on purpose, not for tidiness.
◆ AND THE FAKE SOCKET'S BEHAVIOUR IS THE PART TO GET RIGHT.
close() is the one method with a rule that is not obvious and that the tests lean on: a socket closed
@@ -30,6 +49,15 @@ var clock = { now: 0, next: 1, timers: {} };
/** Every socket the page has opened, in order, live or dead. */
var sockets = [];
/** Every frame the page has sent, in order, as arrays of byte values. */
var outbound = [];
/** Every fake terminal the page has opened on a pane, in the order it opened them. */
var terminals = [];
/** What the last right-click did about the browser's own menu. See rightClick(). */
var gesture = { prevented: 0, stopped: 0 };
function listeners(target) {
target.handlers = {};
@@ -41,11 +69,11 @@ function listeners(target) {
target.handlers[name].push(handler);
};
target.fire = function (name) {
target.fire = function (name, event) {
var handlers = target.handlers[name] || [];
for (var i = 0; i < handlers.length; i++) {
handlers[i]({});
handlers[i](event || {});
}
};
@@ -67,7 +95,22 @@ FakeSocket.OPEN = 1;
FakeSocket.CLOSING = 2;
FakeSocket.CLOSED = 3;
FakeSocket.prototype.send = function () {};
/**
* Records rather than discards, because half of what the page does on a right-click is a frame.
*
* Kept as raw bytes and decoded on the way out — see sentFrame() — so that a payload the page wrote as
* UTF-8 is read back as UTF-8 rather than as whatever a stringification would have made of it.
*/
FakeSocket.prototype.send = function (buffer) {
var bytes = new Uint8Array(buffer);
var copy = [];
for (var i = 0; i < bytes.length; i++) {
copy.push(bytes[i]);
}
outbound.push(copy);
};
/** What the page calls. See the note above on why a connecting socket goes quietly. */
FakeSocket.prototype.close = function () {
@@ -93,9 +136,31 @@ var pageRoot = listeners({
var document = listeners({
hidden: false,
getElementById: function (id) { return id === 'root' ? pageRoot : pageBanner; },
createElement: function () { return { dataset: {}, style: {}, remove: function () {} }; },
createElement: function () { return newElement(); },
});
/**
* A pane, as far as the page can tell.
*
* With real dimensions, and not because anything would fail without them — which is the point. The page's
* guard is `clientWidth < MINIMUM_FITTABLE_PIXELS`, and an undefined width compares false against every
* number, so a fake with no dimensions slips past a rule it ought to have been caught by and gets fitted
* anyway. A fake that passes for the wrong reason stops matching the page the day that guard is written
* differently.
*
* Listeners, because the right-click handler is registered on this and there is nowhere else to fire it
* from.
*/
function newElement() {
return listeners({
dataset: {},
style: {},
clientWidth: 800,
clientHeight: 600,
remove: function () {},
});
}
var window = listeners({});
// The page warns through this on paths no reconnection test reaches. Present so that a test which does
@@ -118,6 +183,142 @@ function ResizeObserver() {
this.observe = function () {};
}
// ── The pane's three globals, which the page reaches by the names the UMD builds attach ──────────────
/**
* A terminal that remembers what was done to it.
*
* `selection` is a plain field rather than anything computed, and settable from a test through select():
* a selection is made by dragging a mouse across a canvas, which is the one part of this there is no
* standing in for. What the tests are about is what the page does with whatever is selected.
*/
function FakeTerminal(options) {
this.options = options || {};
this.cols = 80;
this.rows = 24;
this.selection = '';
this.pastes = [];
this.inputs = [];
this.writes = [];
this.focuses = 0;
this.disposed = false;
this.pane = null;
terminals.push(this);
}
FakeTerminal.prototype.loadAddon = function () {};
FakeTerminal.prototype.open = function (pane) { this.pane = pane; };
FakeTerminal.prototype.attachCustomKeyEventHandler = function (handler) { this.keys = handler; };
FakeTerminal.prototype.onData = function (handler) { this.data = handler; };
FakeTerminal.prototype.onResize = function (handler) { this.resized = handler; };
FakeTerminal.prototype.focus = function () { this.focuses++; };
FakeTerminal.prototype.paste = function (text) { this.pastes.push(text); };
FakeTerminal.prototype.input = function (text) { this.inputs.push(text); };
FakeTerminal.prototype.getSelection = function () { return this.selection; };
FakeTerminal.prototype.clearSelection = function () { this.selection = ''; };
FakeTerminal.prototype.dispose = function () { this.disposed = true; };
/**
* The write callback is the acknowledgement, so it is called rather than kept.
*
* Synchronously, which a real xterm does not promise — it renders first. The page's contract is that the
* acknowledgement is sent from this callback and not before, and calling it straight away is what lets a
* test assert that without needing a frame clock.
*/
FakeTerminal.prototype.write = function (data, done) {
this.writes.push(data);
if (done) {
done();
}
};
var Terminal = FakeTerminal;
var FitAddon = {
FitAddon: function () {
this.fit = function () {};
},
};
var WebglAddon = {
WebglAddon: function () {
this.onContextLoss = function () {};
this.dispose = function () {};
},
};
// ── UTF-8, because the page encodes and decodes every payload through these ──────────────────────────
//
// Written out rather than stubbed as one byte per character. The page sends a selection as UTF-8 and reads
// a clipboard as UTF-8, so a harness that treated the two as Latin-1 would pass a test with an em dash in
// the selection and ship a page that mangled it.
function TextEncoder() {
this.encode = function (text) {
var bytes = [];
for (var i = 0; i < text.length; i++) {
var point = text.charCodeAt(i);
// A surrogate pair is one code point in two units. Combined here, because encoding each half on its
// own is exactly the mojibake this exists to catch.
if (point >= 0xd800 && point <= 0xdbff && i + 1 < text.length) {
var low = text.charCodeAt(i + 1);
if (low >= 0xdc00 && low <= 0xdfff) {
point = 0x10000 + ((point - 0xd800) << 10) + (low - 0xdc00);
i++;
}
}
if (point < 0x80) {
bytes.push(point);
} else if (point < 0x800) {
bytes.push(0xc0 | (point >> 6), 0x80 | (point & 0x3f));
} else if (point < 0x10000) {
bytes.push(0xe0 | (point >> 12), 0x80 | ((point >> 6) & 0x3f), 0x80 | (point & 0x3f));
} else {
bytes.push(
0xf0 | (point >> 18),
0x80 | ((point >> 12) & 0x3f),
0x80 | ((point >> 6) & 0x3f),
0x80 | (point & 0x3f));
}
}
return new Uint8Array(bytes);
};
}
function TextDecoder() {
this.decode = function (bytes) {
var text = '';
for (var i = 0; i < bytes.length;) {
var first = bytes[i];
var length = first < 0x80 ? 1 : first < 0xe0 ? 2 : first < 0xf0 ? 3 : 4;
var point = length === 1 ? first : first & (0x7f >> length);
for (var n = 1; n < length; n++) {
point = (point << 6) | (bytes[i + n] & 0x3f);
}
i += length;
if (point > 0xffff) {
point -= 0x10000;
text += String.fromCharCode(0xd800 + (point >> 10), 0xdc00 + (point & 0x3ff));
} else {
text += String.fromCharCode(point);
}
}
return text;
};
}
// ── What the tests drive the page with ──────────────────────────────────────────────────────────────
/**
@@ -165,6 +366,134 @@ function banner() {
return pageBanner.textContent;
}
/** How many frames the page has sent since it loaded. */
function sentFrames() {
return outbound.length;
}
/**
* One frame the page sent, as "opcode/session/payload", the payload decoded as UTF-8.
*
* One string rather than three values because it crosses into C# as one, and a test asserting on
* "5/7/whoami" reads as the frame it is. Resize frames land here too and read as rubbish; nothing asserts
* on those, and rendering them some other way would mean a second shape for a reader to learn.
*/
function sentFrame(index) {
var bytes = outbound[index];
var session = ((bytes[1] << 24) | (bytes[2] << 16) | (bytes[3] << 8) | bytes[4]) >>> 0;
return bytes[0] + '/' + session + '/' + new TextDecoder().decode(bytes.slice(5));
}
/** Hands the page a frame, on whichever socket is its current one. */
function deliverFrame(opcode, sessionId, payload) {
var bytes = new Uint8Array(5 + payload.length);
var header = new DataView(bytes.buffer);
header.setUint8(0, opcode);
header.setUint32(1, sessionId);
bytes.set(payload, 5);
sockets[sockets.length - 1].fire('message', { data: bytes.buffer });
}
/** SessionOpened, which is what makes the page build a pane. */
function openSession(sessionId) {
deliverFrame(2, sessionId, []);
}
/** Clipboard: the host's answer to a right-click paste. */
function deliverClipboard(sessionId, text) {
deliverFrame(8, sessionId, new TextEncoder().encode(text));
}
/** Paste: a snippet off the sidebar, which is deliberately not the same frame. */
function deliverSnippet(sessionId, text, execute) {
var body = new TextEncoder().encode(text);
var payload = new Uint8Array(1 + body.length);
payload[0] = execute ? 1 : 0;
payload.set(body, 1);
deliverFrame(6, sessionId, payload);
}
/** The pane behind one session, found the way the page labelled it. */
function terminalFor(sessionId) {
for (var i = 0; i < terminals.length; i++) {
if (terminals[i].pane && terminals[i].pane.dataset.sessionId === String(sessionId)) {
return terminals[i];
}
}
throw new Error('No pane for session ' + sessionId + '.');
}
/** Drags a selection across that pane, which is the one thing a fake terminal cannot do for itself. */
function select(sessionId, text) {
terminalFor(sessionId).selection = text;
}
/** What is selected in that pane now — how a test sees that a copy dropped it. */
function selection(sessionId) {
return terminalFor(sessionId).selection;
}
/**
* The right button, over that pane.
*
* The event carries only the two methods the page calls on it, and counts the calls: suppressing the
* browser's own menu is half of what this gesture has to do, and a handler that copied correctly and left
* the menu up would be a regression no assertion about frames could see.
*/
function rightClick(sessionId) {
gesture = { prevented: 0, stopped: 0 };
terminalFor(sessionId).pane.fire('contextmenu', {
preventDefault: function () { gesture.prevented++; },
stopPropagation: function () { gesture.stopped++; },
});
}
/**
* The right button somewhere that is not a pane — the status banner, in practice.
*
* Fired on the document rather than on an element, which is where the page's fallback rule lives: a pane
* stops the event before it gets here, so this is the path only a click that missed every pane takes.
*/
function rightClickOffAPane() {
gesture = { prevented: 0, stopped: 0 };
document.fire('contextmenu', {
preventDefault: function () { gesture.prevented++; },
stopPropagation: function () { gesture.stopped++; },
});
}
/** Whether the last right-click took the event away from the browser, and from xterm. */
function prevented() {
return gesture.prevented;
}
function stopped() {
return gesture.stopped;
}
/** Everything paste() was given for that pane, joined — the bracketed route. */
function pasted(sessionId) {
return terminalFor(sessionId).pastes.join('|');
}
/** Everything input() was given for that pane, joined — the typed route. */
function typed(sessionId) {
return terminalFor(sessionId).inputs.join('|');
}
/** How many times that pane has been asked for the caret. */
function focuses(sessionId) {
return terminalFor(sessionId).focuses;
}
/** Whether that attempt has been closed, by the page or by the far end. */
function isClosed(index) {
return sockets[index].readyState === FakeSocket.CLOSED;
@@ -0,0 +1,276 @@
namespace DodoSSH.Client.Terminal.Tests;
/// <summary>
/// The renderer page's right button: a copy, a paste, and never a menu.
/// </summary>
/// <remarks>
/// <para>
/// The gesture every terminal has had since the Windows console got QuickEdit — select and right-click to
/// copy, right-click with nothing selected to paste. It lives in the page because that is the only place it
/// can: 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.
/// </para>
/// <para>
/// What is asserted here is which frame the page sends, addressed to which session, and which of xterm's two
/// insertion routes it takes on the way back. What is deliberately <em>not</em> asserted is anything about
/// xterm itself — whether <c>paste()</c> really brackets, whether a selection really loses its right
/// padding — because the fake terminal in the harness is a stub with a memory and not an emulator. That
/// boundary is where <c>docs/manual-checks.md</c> 1.11 picks up.
/// </para>
/// </remarks>
public sealed class RendererClipboardTests
{
/// <summary>The session every single-pane test here opens.</summary>
private const uint SessionId = 7;
[Fact]
public void ARightClickOverASelection_CopiesIt()
{
var page = Attached();
page.Do("select(7, 'error: nothing was written')");
page.Do("rightClick(7)");
page.Sent.ShouldContain("5/7/error: nothing was written");
}
/// <remarks>
/// The console's behaviour, and load-bearing rather than cosmetic. Dropping the selection is the only
/// acknowledgement of the copy there is — nothing else on screen moves — and it is what makes the next
/// right-click a paste rather than a second copy of something forgotten about.
/// </remarks>
[Fact]
public void ARightClickOverASelection_DropsTheSelectionAfterCopyingIt()
{
var page = Attached();
page.Do("select(7, 'systemctl status sshd')");
page.Do("rightClick(7)");
page.Selection(SessionId).ShouldBeEmpty();
}
[Fact]
public void ARightClickWithNothingSelected_AsksTheHostForTheClipboard()
{
var page = Attached();
page.Do("rightClick(7)");
// No payload: the page has nothing to send, which is the whole reason it has to ask.
page.Sent.ShouldContain("6/7/");
}
/// <remarks>
/// The composite property, and the one a user would describe: copy, then paste. It passes only if the
/// copy dropped the selection — a page that left it standing sends a second copy frame here and never a
/// paste request, which is the failure that reads as "paste is broken".
/// </remarks>
[Fact]
public void ASecondRightClick_PastesBecauseTheFirstOneCopied()
{
var page = Attached();
page.Do("select(7, 'df -h')");
page.Do("rightClick(7)");
page.Do("rightClick(7)");
page.Sent.ShouldContain("5/7/df -h");
page.Sent.ShouldContain("6/7/");
}
/// <remarks>
/// <para>
/// <c>preventDefault</c> is the WebView's own context menu, which is what this whole change is about.
/// </para>
/// <para>
/// <c>stopPropagation</c> is xterm's, and it is the half that is easy to leave out. xterm registers its
/// own <c>contextmenu</c> handler on the element inside the pane, which parks a 20-pixel textarea under
/// the pointer at z-index 1000 and leaves the selection sitting in it, selected — both of which exist to
/// serve the native menu and are litter without one. The page takes the event in the capture phase to
/// get ahead of it; a listener that forgot the third argument to <c>addEventListener</c> would still
/// suppress the menu and would fail here.
/// </para>
/// </remarks>
[Fact]
public void ARightClick_TakesTheEventFromTheBrowserAndFromXterm()
{
var page = Attached();
page.Do("rightClick(7)");
page.Prevented.ShouldBe(1);
page.Stopped.ShouldBe(1);
}
/// <remarks>
/// The panes cover the page — they are positioned at inset 0 — so the only right-click that misses one is
/// on the status banner, which is drawn over the bottom of the pane while it has something to say. That
/// used to be the one place in a terminal window that still opened a browser menu.
/// </remarks>
[Fact]
public void ARightClickThatMissesEveryPane_StillOpensNoMenu()
{
var page = Attached();
page.Do("rightClickOffAPane()");
page.Prevented.ShouldBe(1);
// And nothing is stopped, because there is nothing below the document to stop it from — nor any
// frame sent, because a click that is on no pane is a click about no session.
page.Stopped.ShouldBe(0);
}
/// <remarks>
/// A right-click over a pane with a selection sends one frame and not two: the branch is exclusive, and a
/// page that asked for the clipboard as well would paste over the selection it had just copied.
/// </remarks>
[Fact]
public void ARightClickOverASelection_DoesNotAlsoAskForTheClipboard()
{
var page = Attached();
page.Do("select(7, 'uname -a')");
page.Do("rightClick(7)");
page.Sent.ShouldNotContain("6/7/");
}
/// <remarks>
/// Two panes, because the session id on a clipboard frame is the whole of what puts the text where the
/// pointer was. They agree with the host's own idea of the active tab in every arrangement that exists
/// today; the page is what knows, so this is what proves the page says.
/// </remarks>
[Fact]
public void ARightClick_IsAddressedToThePaneItHappenedIn()
{
var page = Attached();
page.Do("openSession(9)");
page.Do("select(7, 'the first pane'); select(9, 'the second pane')");
page.Do("rightClick(9)");
page.Sent.ShouldContain("5/9/the second pane");
page.Sent.ShouldNotContain("5/7/the first pane");
}
/// <remarks>
/// The other half of the round trip. <c>paste()</c> rather than <c>input()</c>, because this text came
/// off the system clipboard and that is the case bracketed paste exists for: a shell that receives a
/// multi-line command inside the markers treats every newline as text rather than as "run this".
/// </remarks>
[Fact]
public void TheClipboardComingBack_IsPastedRatherThanTyped()
{
var page = Attached();
page.Do("deliverClipboard(7, 'sudo systemctl restart sshd')");
page.Pasted(SessionId).ShouldBe("sudo systemctl restart sshd");
page.Typed(SessionId).ShouldBeEmpty();
}
/// <remarks>
/// No Enter, ever. A clipboard paste inserts and stops; pressing Enter for somebody is what the sidebar's
/// RUN does, and that is a thing they asked for by name.
/// </remarks>
[Fact]
public void TheClipboardComingBack_DoesNotPressEnter()
{
var page = Attached();
page.Do("deliverClipboard(7, 'rm -rf /var/tmp/build')");
page.Typed(SessionId).ShouldBeEmpty();
}
/// <summary>The caret follows the paste, so the next keystroke lands where the text did.</summary>
[Fact]
public void TheClipboardComingBack_LeavesTheCaretInThatPane()
{
var page = Attached();
var before = page.Focuses(SessionId);
page.Do("deliverClipboard(7, 'whoami')");
page.Focuses(SessionId).ShouldBe(before + 1);
}
/// <remarks>
/// Multi-byte, because both directions run through the page's own <c>TextEncoder</c> and
/// <c>TextDecoder</c> and a character that straddles nothing is not a test of either. The harness's codec
/// is a real UTF-8 one for this reason; see its remark.
/// </remarks>
[Fact]
public void TheTextSurvivesTheRoundTrip_WhenItIsNotAscii()
{
var page = Attached();
page.Do("select(7, 'échec — 90° · λ')");
page.Do("rightClick(7)");
page.Do("deliverClipboard(7, 'échec — 90° · λ')");
page.Sent.ShouldContain("5/7/échec — 90° · λ");
page.Pasted(SessionId).ShouldBe("échec — 90° · λ");
}
/// <remarks>
/// The distinction the new opcode exists to keep, asserted from the other side: a snippet off the sidebar
/// is still <em>typed</em> when it is a single line, because bash marks a bracketed paste as an active
/// region and a snippet should read as though it had been typed. Sharing one opcode with the clipboard
/// would have meant choosing one of these two behaviours for both.
/// </remarks>
[Fact]
public void ASingleLineSnippet_IsStillTypedRatherThanPasted()
{
var page = Attached();
page.Do("deliverSnippet(7, 'tail -f /var/log/syslog', false)");
page.Typed(SessionId).ShouldBe("tail -f /var/log/syslog");
page.Pasted(SessionId).ShouldBeEmpty();
}
/// <inheritdoc cref="ASingleLineSnippet_IsStillTypedRatherThanPasted" />
[Fact]
public void AMultiLineSnippet_IsStillPastedRatherThanTyped()
{
var page = Attached();
page.Do("deliverSnippet(7, 'cd /srv\\nmake\\nmake install', false)");
page.Pasted(SessionId).ShouldBe("cd /srv\nmake\nmake install");
page.Typed(SessionId).ShouldBeEmpty();
}
/// <remarks>
/// And the Enter goes through the typed route even for a multi-line snippet, deliberately outside the
/// bracketing: a <c>\r</c> inside the wrapper arrives at the shell as a literal carriage return and
/// nothing runs.
/// </remarks>
[Fact]
public void ASnippetWithRun_PressesEnterOutsideTheBrackets()
{
var page = Attached();
page.Do("deliverSnippet(7, 'cd /srv\\nmake', true)");
page.Pasted(SessionId).ShouldBe("cd /srv\nmake");
page.Typed(SessionId).ShouldBe("\r");
}
/// <summary>A page with an open socket and one pane, which is where every test here starts.</summary>
private static RendererPage Attached()
{
var page = RendererPage.Load();
// The socket has to be open before anything is sent: send() drops a frame when it is not, which
// would make every assertion here pass or fail on the transport rather than on the gesture.
page.Do("accept(0)");
page.Do("openSession(7)");
return page;
}
}
@@ -44,6 +44,60 @@ internal sealed class RendererPage
/// <summary>What the status element says — the banner a user would be looking at.</summary>
public string Banner => engine.Evaluate("banner()").AsString();
/// <summary>
/// Every frame the page has sent, each as <c>opcode/session/payload</c>.
/// </summary>
/// <remarks>
/// Materialised on each read rather than cached, because the point of it is what the page did in answer
/// to the last <see cref="Do"/>. A session's own resize frames are in here too and read as rubbish; see
/// the harness's <c>sentFrame()</c> for why they are not rendered some other way.
/// </remarks>
public IReadOnlyList<string> Sent
{
get
{
var count = (int)engine.Evaluate("sentFrames()").AsNumber();
var frames = new List<string>(count);
for (var index = 0; index < count; index++)
{
frames.Add(
engine
.Evaluate(string.Create(CultureInfo.InvariantCulture, $"sentFrame({index})"))
.AsString());
}
return frames;
}
}
/// <summary>How many times the last right-click called <c>preventDefault</c>, and <c>stopPropagation</c>.</summary>
/// <remarks>
/// Both, because they answer different questions: the first is whether the WebView's own context menu
/// was suppressed, and the second is whether xterm's own right-click handling was — which is what keeps
/// a 20-pixel textarea from being parked under the pointer with the selection sitting in it.
/// </remarks>
public int Prevented => (int)engine.Evaluate("prevented()").AsNumber();
/// <inheritdoc cref="Prevented" />
public int Stopped => (int)engine.Evaluate("stopped()").AsNumber();
/// <summary>What is selected in that session's pane.</summary>
public string Selection(uint sessionId) =>
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"selection({sessionId})")).AsString();
/// <summary>Everything <c>term.paste</c> was given for that session — the bracketed route.</summary>
public string Pasted(uint sessionId) =>
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"pasted({sessionId})")).AsString();
/// <summary>Everything <c>term.input</c> was given for that session — the typed route.</summary>
public string Typed(uint sessionId) =>
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"typed({sessionId})")).AsString();
/// <summary>How many times that session's pane has been asked for the caret.</summary>
public int Focuses(uint sessionId) =>
(int)engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"focuses({sessionId})")).AsNumber();
/// <summary>
/// Loads the harness and then the page, leaving the page exactly as it is a moment after the WebView
/// navigated to it: one socket opened and still connecting.
@@ -356,6 +356,82 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
steps[0].ShouldBe(-1);
}
/// <remarks>
/// Dispatched before the session lookup for the reason the text-size chord is, and one of its own: a
/// clipboard is not a property of any terminal. The absence of a registered session is the point of the
/// test — copying the last thing a remote said before it hung up is an ordinary thing to want, and a
/// dead tab must not be the one place the gesture silently does nothing.
/// </remarks>
[Fact]
public async Task ACopy_IsHeardWithNoSessionRegistered()
{
Start();
using var socket = await ConnectAsync();
var copied = new List<string>();
plane.ClipboardCopyRequested += (_, e) => copied.Add(e.Text);
await SendAsync(
socket,
(byte)TerminalClientOpcode.ClipboardCopy,
Encoding.UTF8.GetBytes("error: nothing was written"));
await WaitUntilAsync(() => copied.Count > 0);
copied[0].ShouldBe("error: nothing was written");
}
/// <remarks>
/// An empty copy is dropped rather than forwarded. The clipboard is shared with everything else on the
/// machine, so "copy nothing" would be a gesture that quietly destroys whatever was on it — and the page
/// does not send one, which makes this a guard against a frame from something that is not the page.
/// </remarks>
[Fact]
public async Task AnEmptyCopy_IsDropped()
{
Start();
using var socket = await ConnectAsync();
var copies = 0;
plane.ClipboardCopyRequested += (_, _) => Interlocked.Increment(ref copies);
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardCopy, []);
// Followed by one that must be heard, so the assertion below is about ordering rather than about
// having waited long enough: the receive loop is sequential, so a copy arriving after this proves
// the empty one was seen and passed over.
var copied = new List<string>();
plane.ClipboardCopyRequested += (_, e) => copied.Add(e.Text);
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardCopy, "uptime"u8.ToArray());
await WaitUntilAsync(() => copied.Count > 0);
copies.ShouldBe(1, "only the second frame carried anything");
}
/// <remarks>
/// The session id is the whole payload of a paste request — it says which pane the pointer was over, and
/// therefore where the answer has to land. Heard with nothing registered for the reason a copy is.
/// </remarks>
[Fact]
public async Task APasteRequest_CarriesTheSessionItWasMadeIn()
{
Start();
using var socket = await ConnectAsync();
var asked = new List<uint>();
plane.ClipboardPasteRequested += (_, e) => asked.Add(e.SessionId);
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardPaste, [], sessionId: 4242);
await WaitUntilAsync(() => asked.Count > 0);
asked[0].ShouldBe(4242u);
}
[Fact]
public async Task AFrameForAnUnregisteredSession_IsIgnored()
{
@@ -268,6 +268,69 @@ public sealed class TerminalWorkspaceTests
.ShouldBeFalse("the tab it names is gone");
}
/// <remarks>
/// <para>
/// The right-click paste's answer, and it is a separate frame from the snippet one above rather than the
/// same frame with a flag: the page brackets this unconditionally and types a single-line snippet, which
/// is a difference of behaviour and not of degree. See <see cref="TerminalServerOpcode.Clipboard"/>.
/// </para>
/// <para>
/// The payload is the clipboard's text and nothing else — no leading flag byte, because there is no
/// Enter to ask for. A frame that grew one would put a stray character at the front of every paste, and
/// this is what would catch it.
/// </para>
/// </remarks>
[Fact]
public async Task TheClipboard_ReachesThePaneThatAskedForIt()
{
var connections = new FakeConnectionFactory(blockShellReads: true);
await using var workspace = CreateWorkspace(connections);
workspace.Start();
using var renderer = await ConnectRendererAsync(workspace);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
// The session's opening frame, read and discarded so the clipboard frame below is the next one.
await ReceiveFrameAsync(renderer);
(await workspace.SendClipboardAsync(
sessionId, "sudo systemctl restart sshd", TestContext.Current.CancellationToken))
.ShouldBeTrue("the session is live");
var frame = await ReceiveFrameAsync(renderer);
frame.Opcode.ShouldBe((byte)TerminalServerOpcode.Clipboard);
frame.SessionId.ShouldBe(sessionId);
Encoding.UTF8.GetString(frame.Payload).ShouldBe("sudo systemctl restart sshd");
}
/// <remarks>
/// Refused rather than sent and dropped, for the reason <c>PasteAsync</c> is — with the difference that
/// nothing on screen would have shown it either way, so this answer exists to be honest rather than to
/// be reported. See <see cref="TerminalWorkspace.SendClipboardAsync"/>.
/// </remarks>
[Fact]
public async Task SendingTheClipboardToADeadSession_SaysSoRatherThanDroppingIt()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
(await workspace.SendClipboardAsync(9999, "uptime", TestContext.Current.CancellationToken))
.ShouldBeFalse("this workspace never issued that id");
await workspace.CloseSessionAsync(sessionId);
(await workspace.SendClipboardAsync(sessionId, "uptime", TestContext.Current.CancellationToken))
.ShouldBeFalse("the tab it names is gone");
}
/// <remarks>
/// The event the tab strip and the phone's keep-alive listen to, so a dot can go out — and a foreground
/// notification can come down — the moment a shell exits rather than at the next thing that happens to