Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

◆ A READER THAT STOPS EARLY, UNDER pipefail.

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

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

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

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

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

◆ WHY IT LOOKED LIKE SOMEBODY ELSE'S COMMIT.

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

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

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

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

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

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

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

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

◆ THREE SECONDS OF NOTHING.

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

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

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

Two mechanics are load-bearing rather than incidental.

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

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

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

◆ AND ONE INSTANCE.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

◆ WHAT IS NOT HERE.

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

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

◆ TESTS, AND WHAT IS LEFT FOR A PERSON.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard.

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

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

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

◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

◆ THE FOCUS FIGHT ENDS BY REMOVING A CONTESTANT.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

◆ WHAT IS VERIFIED.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

◆ WHAT IS ACTUALLY VERIFIED.

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

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

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

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

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

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

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

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

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

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

An administrator who does not accept that masks the file:

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

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

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

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

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

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

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

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

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

Three details that are load-bearing rather than incidental:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

x86_64 only, for that reason and not a different one.
2026-08-22 16:43:38 +02:00
jaap-jan 1970e20e56 Merge pull request 'Type a path on the SFTP pane, and put a file right without deleting it' (#15) from claude/dodossh-sftp-improvements-3b6305 into main
ci / desktop nightly (push) Successful in 48s
ci / build and test (push) Successful in 2m29s
ci / android head (push) Successful in 3m15s
ci / api image (push) Successful in 32s
Reviewed-on: #15
2026-08-21 12:03:50 +00:00
jaap-jan b2f93a172b Give the file listings' columns their content back
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m7s
ci / build and test (pull_request) Successful in 2m32s
ci / api image (pull_request) Successful in 4s
The columns in both panes were fixed pixel widths, and measuring them against
what they actually draw found them wrong at both ends of the range this screen
gets.

◆ MODIFIED WAS TRUNCATED AT EVERY WIDTH THE APPLICATION HAS EVER BEEN DRAWN AT.
It prints a sixteen-character timestamp, which needs 116 pixels, and it had 52.
On a maximised window that read "2026-08" beside a NAME column with four
hundred empty pixels in it. PERMS needs 60 for a ten-character mode and had 48;
SIZE needs 58 for "999.9 GB" and had 56.

◆ AND AT THE SESSION SHELL'S OWN NARROWEST, NAME MEASURED ZERO PIXELS. Twenty
for the glyph plus 56+52+48 for the numbers is 176 of the 180 a row has, so the
remote pane drew sizes, timestamps and modes for rows whose names were not on
screen at all. That is the same fault as the one above rather than a second
one: the numbers were picked for the narrow budget, could not have fitted their
own content there, and never grew anywhere else.

Star columns with a MaxWidth answer both, because the cap is what makes "share
the width" mean the right thing at each end. Wide, the three numeric columns
stop growing at what their text needs and NAME keeps every remaining pixel.
Narrow, nothing carries a MinWidth to overflow with, so all four give ground
together and NAME — weighted heaviest — is the last to run out. MODIFIED takes
the extra weight so it reaches its cap first, being the widest content and the
column most often actually read; the local pane's NAME carries the remote's
PERMS weight folded into it, which is what makes SIZE and MODIFIED come out the
same width on both sides of the arrow column. Timestamps.Format already argued
that both panes write a time the same way so the two can be read against each
other, and two different widths would have made that argument for nothing.

◆ THE HEADINGS RAN TOGETHER, and that was visible as one word: SIZE ended on
the pixel MODIFIED began at, so the row read "SIZEMODIFIED". They are
right-aligned in their own columns, so the gap has to come from a left margin
on each — which does not move the label and does stop the column before it
reaching this one. The three now carry one, and ellipsize rather than clip
where even the label has no room.

◆ TWO PLACEHOLDERS WERE CUT OFF MID-WORD, which reads as a rendering fault
rather than as a hint. MKDIR's box drew "new directo" — 101 pixels of text in
74 pixels of room — and says "directory" now, since the button beside it
carries the "new" half already. The pane header's path box drew a path ending
mid-segment at the narrow budget and says "path": the only time that
placeholder shows is on a pane with nothing connected, where the box is
disabled and the invitation below is doing the explaining, and what the box is
for is in its tooltip where there is room for it.

FOUR COPIES OF THE COLUMN DEFINITIONS, one heading row and one row template per
pane. Avalonia cannot share a ColumnDefinitions instance between two Grids —
each mutates its own computed sizes — and SharedSizeGroup only applies to Auto
columns, so what catches a copy that drifts is measurement rather than
discipline: every cell is held against the width its own text needs when
unconstrained, the headings against each other, and NAME against zero at the
narrowest budget. Nothing asserts a number this file also wrote down, which is
the failure mode a test of a layout most easily has.

The roomy end is measured at the width a maximised 1843-pixel window leaves,
which is where this was reported from; LayoutHarness.SessionScreenWidth cannot
express it because that constant is pinned to the window's minimum, so the
arithmetic is spelled out beside the test that uses it.
2026-08-21 14:01:45 +02:00
jaap-jan 882f01c515 Type a path on the SFTP pane, and put a file right without deleting it
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 23s
ci / android head (push) Successful in 3m27s
ci / desktop nightly (push) Successful in 53s
ci / build and test (pull_request) Successful in 2m30s
ci / android head (pull_request) Successful in 7m21s
ci / build and test (push) Successful in 2m28s
ci / api image (push) Successful in 36s
Three things the file browser could not do. Somewhere to go had to be walked
to, a file that landed under the wrong name could only be removed and sent
again, and the listing went on showing the directory as it was before this
application put something in it.

◆ THE PATH IS A BOX NOW, not a TextBlock printing where the pane was. A
breadcrumb only reaches what is under the directory already listed, so crossing
a host's filesystem cost a click and a round trip a level, and a path somebody
already knew could not be entered at all. Enter goes; Escape puts back what the
pane is really showing.

What is typed may be absolute, relative to the directory showing, or start at
~ — the four shapes a shell takes, because somebody typing into a box is typing
what they would type into a shell. SftpPath.Resolve turns all of them into one
absolute path, and it resolves them here rather than handing a relative path to
the server: the server would resolve it against the session's own working
directory, which is not necessarily the directory the breadcrumb above the box
is showing. `..` is collapsed textually, which differs from cd through a
symbolic link; the alternative is a realpath round trip before anything lists.

The box holds its own value rather than RemotePath itself. Binding it straight
to the path would navigate on every keystroke — `/`, then `/v`, then `/va` — a
round trip and a failed listing per character. Every other way of moving writes
the real path back into it; a refused listing deliberately does not, because
the path that did not resolve is the one worth having in front of you.

◆ RENAME AND MOVE, and DELETE moves to join them. It had been sitting in the
pane header beside UP and REFRESH — two things that move the pane and one that
destroys a file, in one row — and the three that act on the selected row are
now together in a row of their own, under the name of the row they are about.

One SFTP rename underneath both new operations and two separate questions on
the screen, because the answer is a different kind of thing each time: a rename
takes a name and must not take a path, a move takes a directory and must not
take a name. One box asking for a destination path would be fewer controls and
a worse question — the two mistakes it invites are exactly the ones the split
makes impossible. Each form opens on the answer that changes nothing, so what
gets edited is the half somebody came to change.

Three refusals are made here, and they are the ones a server's message would
not explain: a separator in a name (no such file, about a directory nobody
meant to name), a destination equal to the source (success, and a file that did
not move), and a directory into itself (EINVAL, true and useless). Everything
else is the server's — a name already taken, a path that is not there, a mount
that cannot be crossed — and arrives in the status line in its own words.
Neither is painted in the deletion card's red: a rename is undone by renaming
back, and spending that red twice makes it stop meaning anything.

◆ THE LISTING REFRESHES ITSELF when a transfer lands in it. Both panes, since
an upload lands on the host and a download lands here and either leaves the
pane that is showing that directory a step behind. Only the directory actually
on screen: a queue moving a hundred files elsewhere must not be a round trip
per file for a listing nobody is looking at.

It waits rather than interrupting. Another operation running would have two
listings racing for one collection, and re-listing under an open question is
how the row somebody answered about stops being the row that gets acted on — so
the staleness is remembered and settled at the end of the operation or when the
question is dismissed. It says nothing either way: nobody asked for it, and the
status line at that moment is carrying the news about the transfer. The
selection survives, re-found by path, which is what makes an unasked-for
refresh safe to run under somebody's pointer at all.

◆ THE LAYOUT SUITE FOUND A REAL FAULT and the fix is where the height goes. The
rename form did not fit the 204 pixels the session shell's own sidebar leaves
this pane — MKDIR and the listing were pushed off the bottom. The form is
shorter now (the placeholder says what the box wants, so there is no field
label above it), and the item row and the breadcrumb step aside while any of
the three questions is up, which is what the phone head already did with its
own buttons.

Measuring also settled something the markup had been claiming and is not true:
at that width the listing collapses to nothing under any of the three cards,
the deletion card included, and has done since the sidebar arrived. So "the row
it is about is still on screen" is retired from the deletion card's remark, and
the full path each card carries is what identifies the row instead. Recorded
where it is drawn rather than left to be rediscovered.

TESTS. SftpPath.Resolve gets the four shapes, ~ with and without a home to
expand it to, a blank box, and climbing past the root. ShellFlowTests gets nine
against a session that records what it was asked to do — which path was
renamed, which directories were listed — rather than against the sentence this
code writes about itself: the path box absolute and relative, rename in place,
the separator refusal, a move by relative path, the no-op refusal, the three
questions displacing each other, and the three rules the automatic refresh
follows. The local half is in TransferQueueingTests, where a real folder
answers. Two new shapes are measured, and the deletion card's own measurement
moves to the connected state — the only state it can be asked in, and the
narrow one.

What the tests cannot reach is in docs/manual-checks.md as phase 2b: a real
server's cross-device and name-taken refusals, and a soft keyboard, which is
why the phone's path box has a GO button rather than trusting whatever the IME
decided to draw on its return key.
2026-08-21 12:05:33 +02:00
jaap-jan 2f8daacb60 Merge pull request 'Reconnect the terminal view when somebody comes back to it' (#14) from claude/terminal-reconnection-stuck-4e57ff into main
ci / build and test (push) Successful in 2m47s
ci / desktop nightly (push) Successful in 46s
ci / android head (push) Successful in 3m47s
ci / api image (push) Successful in 33s
Reviewed-on: #14
2026-08-14 14:13:28 +00:00
jaap-jan ef12e8cc99 Stop the data plane's tests racing the socket they just connected
ci / build and test (pull_request) Successful in 2m30s
ci / android head (pull_request) Successful in 3m19s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 21s
CI went red on the run that added the renderer tests, and not on anything they
assert: TerminalDataPlaneTests.Output_ReachesTheRenderer read the output frame
where it expected the session's opening one, having lost a race that has been
in the helper since it was written.

Connected and attached are two different moments. ClientWebSocket.ConnectAsync
completes on the 101, which UpgradeAsync writes before it has a WebSocket to
attach — it builds one from the stream and swaps it in a few instructions
later, on the accept thread. SendAsync drops anything sent in between, which is
the transport's documented contract rather than a bug: there is nowhere to put
a frame for a renderer that is not there, and queueing it is the unbounded
growth the credit window exists to prevent. So a helper that returned on the
handshake and let its caller send immediately was betting on thread scheduling,
every run, on every test in the file.

It only started losing now because that assembly grew nine tests and a
JavaScript engine to run them in, which is more work in parallel with a window
measured in instructions. The race is older than the branch that exposed it.

ConnectAsync now waits for the plane's own SocketAttached, subscribed before
the connection because the event can be over before ConnectAsync returns, and
bounded so a socket that never attaches fails the helper instead of hanging the
suite in a later receive. TerminalWorkspaceTests.ConnectRendererAsync has the
same exposure through OpenSessionAsync's opening frame and now waits on
WaitForRendererAsync, with a note on why that is enough for the reattach tests
and what would stop being enough.

◆ NOTHING IN src CHANGED, AND THAT IS THE CONCLUSION RATHER THAN THE SHORTCUT.
Production waits for exactly this moment already — every path that opens a
session goes through TerminalWorkspace.WaitForRendererAsync, which resolves
from the same few lines that raise the event this helper now waits on. Only the
tests skipped the gate the application does not.

Verified by widening the window rather than by hunting the flake: a 100ms delay
inserted between the 101 and the attach, in a throwaway tree, hangs the old
helper outright — both frames dropped, the test blocked in receive — and passes
89/89 with this one. Green three times over on the CI platform besides
(Alpine, musl, Release, dotnet/sdk:10.0-alpine).
2026-08-14 15:44:03 +02:00
jaap-jan 963cb7f670 Reconnect the terminal view when somebody comes back to it
ci / build and test (pull_request) Failing after 2m29s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m23s
Going back to a terminal left alone for a while found it stuck on
"Reconnecting the terminal view…", and stuck is the right word: the banner
stayed and nothing behind it was reconnecting.

The page's whole recovery story was a setTimeout chain, and a chain is exactly
what a WebView is entitled to stop running. Chromium throttles timers in a page
nobody is looking at — down to once a minute once it has been hidden five
minutes — and a renderer that is frozen, or reclaimed and not yet reloaded,
runs none of them. So the socket drops while nobody is watching, the banner
goes up, the retry is scheduled, and the retry is then the one thing not
running.

Three defects, each of which leaves that banner up for the rest of the page's
life.

◆ NOTHING LISTENED FOR THE PAGE COMING BACK. The only thing that could clear
the banner was a timer that may never fire. terminal.js now reconnects on
visibilitychange, focus and online — the events that mean somebody is looking
again, and the ones that cannot be throttled — cancelling the pending timer and
resetting the backoff. Over a healthy socket all three do nothing, which is
what makes them safe to fire as often as clicking a window does.

◆ A HANDSHAKE THAT NEVER FINISHED WAS INVISIBLE. Every retry was scheduled by a
close or an error, so an attempt parked in CONNECTING — which is what a
suspended renderer leaves behind — scheduled nothing at all, ever. There is now
a five-second watchdog on the handshake.

◆ STALE SOCKETS SCHEDULED RETRIES, AND THAT ONE IS A LOOP RATHER THAN A STALL.
connect() never detached the old socket's handlers, and the host aborts the
displaced socket on takeover — TerminalDataPlane.UpgradeAsync, doing exactly
what it should. That close read as a fresh failure and scheduled a retry
against the socket that had just succeeded, whose own close scheduled the next:
no fixed point, reconnecting every second forever with the banner up for most
of it. Every handler now asks whether it is still the page's own attempt, and
connect() closes what it abandons.

◆ WHICH OF THE PLATFORM BEHAVIOURS ACTUALLY BIT IS NOT ESTABLISHED, and the fix
does not depend on knowing. Throttled timers, a frozen renderer and a reclaimed
one all end at the same dead timer; guessing between them would have produced a
narrower fix for one of the three.

THE TEST RUNS terminal.js ITSELF, in a fake browser, inside dotnet test.
RendererPage loads the file the shell project ships — not a transcription of its
logic into C#, which would be a copy that stays correct while the page rots —
into a Jint engine, one per test, over a harness that fakes a WebSocket and a
clock and nothing else. Jint rather than a node script because CI would run the
node one and nobody's inner loop would; the cost is that Jint is not Chromium,
so this proves the page's logic and nothing about how a WebView behaves. That
line is drawn in RendererPage's remark and picked up by two new manual checks,
1.10 for the desktop and 11.12a for the phone, which own the platform half.

Four of the nine tests fail against the page as it stood — the stale close, the
parked handshake, and the two wake-ups. Two more assert that a wake-up over a
healthy socket does nothing, and pass against either version on purpose: they
are what stops the cure being worse.

Left alone deliberately: a socket that is open and dead shows no banner at all,
because readyState still reads OPEN. That looks like a terminal that swallows
what is typed, needs a liveness probe rather than a faster retry, and is written
down at the end of 11.12a rather than quietly bundled in here.
2026-08-14 15:09:43 +02:00
jaap-jan 68964d8a34 Merge pull request 'Name a quick-connect result's vault, and search on it' (#13) from claude/search-items-vault-display-865eb0 into main
ci / build and test (push) Successful in 3m42s
ci / api image (push) Successful in 37s
ci / android head (push) Successful in 3m46s
ci / desktop nightly (push) Successful in 44s
Reviewed-on: #13
2026-08-14 09:13:16 +00:00
jaap-jan 6934efb1e6 Merge pull request 'Take the SSH.NET release that fixes the SCP path traversal' (#12) from claude/sharp-fermat-9b8dc5 into main
ci / android head (push) Canceled after 0s
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 5s
Reviewed-on: #12
2026-08-14 09:13:10 +00:00
jaap-jan e252d337d1 Take the SSH.NET release that fixes the SCP path traversal
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m26s
ci / build and test (pull_request) Successful in 2m27s
ci / api image (pull_request) Successful in 21s
Every project in the repository stopped building, on a warning about a package
none of them had changed:

    error NU1903: Warning As Error: Package 'SSH.NET' 2025.1.0 has a known high
    severity vulnerability

GHSA-q939-rpr3-3284 was published after the pin was written. ScpClient.Download
in recursive mode takes the filenames the server sends and joins them to the
local destination without validating them, so a malicious or compromised SCP
server can name "../" its way out of the download directory and write anywhere
the client process can — CWE-22, 7.1, and the same shape as OpenSSH's
CVE-2019-6111 with the traversal left in. Everything up to and including
2025.1.0 is affected; 2026.0.0 is the fix.

◆ THIS APPLICATION WAS NEVER EXPOSED, AND THE UPGRADE IS STILL THE RIGHT ANSWER.
Nothing here constructs a ScpClient. File transfer is SftpClient throughout —
SshNetSftpSession is the only path to a remote file — so the vulnerable method
has no caller to reach it from. That makes this a hygiene bump rather than an
incident, and it is worth saying plainly because the alternative on offer was a
scoped NuGetAuditSuppress with that reasoning written beside it. A suppression is
what you reach for when there is nothing to upgrade TO. There is: the maintainers
shipped the fix, taking it costs two nullable annotations, and a suppression
would have left this repository carrying a known-vulnerable library and a comment
explaining why that is fine — which stays true only until somebody adds the first
ScpClient call and has no reason to look here.

BouncyCastle.Cryptography moves 2.6.2 -> 2.7.0 as a consequence, not a
preference. CentralPackageTransitivePinningEnabled makes the PackageVersion here
the resolved version for a transitive dependency, and SSH.NET 2026.0.0 asks for
2.7.0, so a pin left at 2.6.2 is a downgrade error rather than a version this
repository gets to choose. Its comment records the constraint so the next person
does not try to walk it back.

◆ THE RELEASE NOTES SAY NO BREAKING CHANGES. THE NULLABLE ANNOTATIONS DISAGREE.
ConnectionInfo.CurrentServerEncryption is now string? — correct of them, since it
really is null until the key exchange completes — and that is CS8601 at both
places this codebase reads a cipher off a live connection. Neither can observe
the null: SshNetConnection and SshNetSftpSession are only ever constructed after
ConnectAsync has been awaited, which is what their existing remarks already say
and why the reads are at construction rather than lazy.

Coalesced to string.Empty rather than silenced with !, because empty is already
this codebase's word for a cipher that could not be read. MainWindowViewModel's
NullIfEmpty exists for exactly that, TerminalWorkspace.GetSessionFacts already
hands over string.Empty on the same grounds, and the status bar collapses on it.
So the boundary keeps the interface's non-nullable string, no consumer changes,
and an unreachable null would degrade the way a real one already does.

SshNetConnection's remark asserted that SSH.NET types the property non-nullable
and that reading straight through was therefore honest rather than lazy. That
sentence is now false, and a comment contradicting the line beneath it is worse
than no comment, so it says what is actually true: the coalesce is there for the
annotation and the pre-key-exchange window this call site cannot be in.

The Android head needed its own restore. It is outside DodoSSH.slnx, so the
solution-wide --force-evaluate does not reach it, and left alone it would have
kept the vulnerable pin and gone stale under locked mode — the hazard
platform-flags.md records, arriving on schedule.

Heeding the warning left by the ILLink commit: --force-evaluate on Windows
rewrote all 39 lock files with CRLF, and 21 of them had no content change. Only
the 18 that really moved are here.

Verified with `dotnet build` at the root, no audit-disabling flags: 0 errors,
NU1903 gone, and the 21 remaining warnings are the pre-existing Meziantou/CA and
Avalonia ones that were present in the failing build too.

A library upgrade is not a version string, so the suites that exercise it were
run against live containers rather than trusted: 87 Ssh, 80 Terminal, 27
ObjectStore, 10 Transfer, none failing.
2026-08-14 09:36:24 +02:00
jaap-jan fdea3911c1 Name a quick-connect result's vault, and search on it
ci / build and test (pull_request) Failing after 7s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Failing after 6s
Ctrl+K reaches every vault the session holds a key for — that is deliberate,
and it is what makes the palette worth opening from anywhere. What it did not
do was say where a result came from. A team and a person who both call a
machine prod-db got two identical rows, and Enter took whichever the ranking
happened to put first: the same three characters, two different machines,
depending on nothing anybody could see.

Nothing new is computed for this. HostRowViewModel.VaultBadge has been filled
in since the host list learned to span vaults, and it is already drawn on the
hosts board, the keychain, the snippets list and the known-hosts pane. The
palette was the one list reaching across every vault that did not print it.

◆ THE VAULT IS THE LAST OF THE FOUR RANKS, AND THAT IS THE POINT. A vault name
is the widest reading of the three the palette had: one word matches every host
in that vault at once, where a name or an address matches one machine. So it
sits behind name-starts-with, name-contains and address — otherwise typing a
machine's name would bury it under everybody else's.

It matches on HasVaultBadge rather than on VaultName, so the search only ever
matches what the row actually shows. A session holding one vault prints no
vault on any row, and matching it there would answer "personal" with the entire
keychain, ranked behind nothing and explained by nothing on screen.

In the row, the right-hand column becomes two lines against the two on the
left, in the same order: what this is above, how it is reached below. With one
vault the badge is empty, the line collapses, and the kind word stays centred
exactly where it was. The name is capped and ellipsised because that column is
Auto-sized — a long vault name would otherwise take its width out of the host
name beside it.

Three tests, one per claim. VaultSharingTests puts platform-gateway in the
personal vault and prod-db in "Platform secrets" and types "platform": both come
back, the gateway first. ShellFlowTests types the personal vault's own name into
a one-vault session and gets nothing. QuickConnectTests is the markup's half —
the shared vault's row draws PLATFORM SECRETS, and a one-vault row draws no
vault at all — with the shared vault put into the session through the layout
suite's own StubTeamServer, as the settings pages' suite does it.

449 App tests and 155 layout tests pass. Both suites were run with
-p:NuGetAudit=false: SSH.NET 2025.1.0 has picked up GHSA-q939-rpr3-3284 and
NU1903 fails restore repo-wide, which predates this branch and is nothing to do
with it. No package or lock file is touched here.
2026-08-14 09:35:44 +02:00
jaap-jan 10f80bded1 Merge pull request 'Colour the window's frame, inset Hosts like its neighbours, drop Pins' (#9) from claude/title-bar-color-windows-6d386c into main
ci / build and test (push) Successful in 2m45s
ci / android head (push) Successful in 3m41s
ci / desktop nightly (push) Successful in 1m29s
ci / api image (push) Successful in 31s
Reviewed-on: #9
2026-08-12 11:12:14 +00:00
jaap-jan 281f849086 Merge pull request 'Look for a newer build the moment the application starts' (#8) from claude/version-check-startup-e06e9a into main
ci / build and test (push) Successful in 2m42s
ci / android head (push) Successful in 3m39s
ci / desktop nightly (push) Successful in 1m4s
ci / api image (push) Successful in 31s
Reviewed-on: #8
2026-08-12 10:40:04 +00:00
jaap-jan 25407756c3 Look for a newer build the moment the application starts
ci / build and test (pull_request) Successful in 2m18s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m29s
ci / api image (pull_request) Successful in 5s
The first pass of the update loop waited two minutes. Every pass after it came
six hours apart, which is the right interval for a product that ships rarely —
but the delay in front of the first one quietly excluded a whole way of using
this application.

A client opened to reach one host and closed again is over before the two
minutes are. Used that way, it never checks at all: not once, not slowly, never.
That is precisely the machine ADR 0011 names as the real cost of distributing
outside a store — quietly a year behind — and the galling part is that the
mechanism to fix it was switched on the whole time and simply never reached.

The delay's own argument is recorded in the diff it is being removed from, and
it was not a bad one: nothing anybody does in their first two minutes depends on
an update, and launch is already contending for the network with a schema
migration, a resumed sign-in and a first sync, at the one moment somebody is
watching the window. What it weighed was the cost of checking early against the
benefit of checking early. It never weighed the cost of not checking at all.

◆ THE YIELD IS WHAT KEEPS THIS OFF THE LAUNCH PATH, AND IT IS NOT DECORATION.
Start() is called from MainWindowViewModel.StartAsync ahead of the migration, so
an inline first pass would run whatever the channel does before its own first
await — Velopack reads the install layout from disk — between the user and their
window. Yielding hands the rest of launch back and puts the check in a later
turn, which is the same moment in every sense anybody can perceive and none of
the cost. So the answer to the delay's argument is not that it was wrong; it is
that a yield buys most of what two minutes bought.

Task.Yield takes no token where Task.Delay did, so the loop body now observes
cancellation at its head. Without that, an application closed during launch
spends its last moment asking a release channel about a build it will not run.

Two things deliberately not changed. The AUTOMATIC UPDATE CHECKS preference
still gates the pass — "on start" means every start, not regardless of what the
user asked for, and that setting is already on by default. And the data cost is
unchanged rather than merely acceptable: a check is a few hundred bytes and the
download only follows if something newer exists, so this moves the same traffic
earlier without adding any. That matters most on the phone, where the same loop
runs against AndroidUpdateChannel.

TheFirstPassRunsAtStart_RatherThanOnADelay drives the real loop rather than
CheckOnceAsync, which is the one thing that file otherwise avoids — and here it
is the point, because the claim is about when the pass happens rather than what
it does. It waits on the pass and not on a clock, so there is nothing to be
flaky about: a regression that puts a delay back does not fail on a margin, it
spins until the suite's own cancellation ends it. DisposingStopsTheLoop keeps
its assertion and gains a note that it is now a race rather than a formality.

§16.7 of the manual checks gains the sentence that reopening the application
does what CHECK NOW does. It is the step somebody following that section would
otherwise discover by accident.

447 App tests and 153 layout tests pass.
2026-08-12 12:03:54 +02:00
jaap-jan a763f4b113 Merge pull request 'Stop the SDK's own trimmer version deciding whether CI can restore' (#11) from claude/illink-lock-drift into main
ci / desktop nightly (push) Successful in 1m17s
ci / api image (push) Successful in 37s
ci / build and test (push) Successful in 2m41s
ci / android head (push) Successful in 3m38s
Reviewed-on: #11
2026-08-12 10:03:22 +00:00
jaap-jan 881562e81b Follow the SDK's ILLink version into the three lock files that pin it
ci / build and test (pull_request) Successful in 2m13s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m22s
ci / api image (pull_request) Successful in 6s
CI's restore stopped on a commit that changed no dependency and touched none of
these files:

    error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has
    changed from [10.0.10, ) to [10.0.11, ).

◆ NOTHING HERE MOVED. THE RUNNER'S SDK DID. Microsoft.NET.ILLink.Tasks is
referenced implicitly by the SDK — no .csproj asks for it — and its version
tracks the runtime patch band. global.json pins 10.0.100 with
rollForward: latestMinor, so setup-dotnet installs whatever the newest 10.x SDK
is on the day. When that band moved to 10.0.11, every lock file carrying the
entry went stale at once, and RestoreLockedMode is what turns that into a stop
rather than a silent upgrade. Three files carry it: the Android head, Contracts
and Crypto.

Contracts and Crypto are regenerated by --force-evaluate under SDK 10.0.303,
which bundles the same 10.0.11 the runner has; the contentHash is NuGet's, from
that restore. dotnet restore DodoSSH.slnx --locked-mode is clean under it.

◆ THE ANDROID LOCK FILE IS HAND-EDITED AND WAS NOT VERIFIED BY A RESTORE. Same
three lines, same version, same hash the real restore produced — but generated
by an editor, not by NuGet. Installing 10.0.303 rewrote the machine-wide
workload manifests for the 10.0.300 band, after which that project fails
NETSDK1147 asking for wasm-tools (which nothing here targets) under 10.0.302 as
well as 10.0.303, so there was no working local restore left to produce it
honestly. The android workload on that machine is Visual Studio-managed and
repairing it is not a thing to do in passing. This is the hazard the docs
already record — the Android head is outside DodoSSH.slnx, so the locked-mode
gate that keeps the other fourteen lock files honest has never seen it — landing
again, from the other direction. If the android job still fails on NU1004, this
line is the first thing to doubt.

platform-flags.md gains the failure beside the existing lock-file hazards,
including the trap that cost the most time here: --force-evaluate from an SDK
older than the runner's rewrites the lock at the OLD version, changes nothing,
and looks like it worked. Check dotnet --version against the version in the
error before believing a regeneration.

This recurs on every SDK patch that moves the band. That is the accepted cost of
letting the SDK float; pinning an exact SDK trades a recurring lock-file bump
for a recurring toolchain bump and one mandated SDK per contributor.
2026-08-12 11:50:42 +02:00
jaap-jan 93e35a0095 Stop the SDK's own trimmer version deciding whether CI can restore
ci / build and test (pull_request) Successful in 2m24s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m22s
ci / api image (pull_request) Successful in 21s
CI went red across the whole repository — main's run 125 and every open pull
request at once — on a restore that never reached a compiler:

    error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has
    changed from [10.0.10, ) to [10.0.11, ). The packages lock file is
    inconsistent with the project dependencies so restore can't be run in
    locked mode.

Nothing in any of those commits touched a package. .NET had shipped SDK 10.0.400.

◆ THE VERSION IN THE LOCK FILES WAS NEVER THIS REPOSITORY'S TO DECIDE.

Microsoft.NET.ILLink.Tasks is referenced by nothing here. The SDK adds it to any
project setting IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
DodoSSH.Crypto do, and the Android head gets it from trimming being on by
default — and it supplies the version itself, from the KnownILLinkPack item in
its own Microsoft.NETCoreSdk.BundledVersions.props. 10.0.302 says 10.0.10;
10.0.400 says 10.0.11.

packages.lock.json records that as a Direct reference with a requested range, so
what the committed file actually means is "whichever SDK last ran a restore".
global.json says rollForward: latestMinor, so setup-dotnet installs the newest
10.x SDK that exists on the morning it runs. The gate did its job — an unreviewed
dependency change is exactly what it is there to stop — but the change it caught
was not one anybody could have reviewed, and it will recur on every servicing
release.

Regenerating the lock files alone would have been the worse repair, and not only
because it holds until the next release. It cannot be done from this machine at
all: every SDK installed here tops out at 10.0.302, which writes 10.0.10 straight
back and re-breaks CI. The recorded version would flip according to who restored
last — the precise state locking exists to prevent.

So the version is pinned in Directory.Build.targets and the three lock files are
regenerated against the pin. It is an Update on the SDK's item rather than a
PackageVersion in Directory.Packages.props because the reference is implicit:
the SDK supplies a version, so central package management is never consulted. It
sits in a target because the conditioning is on %(TargetFramework) — all the
KnownILLinkPack items share one identity and only that metadata separates
net10.0's from net8.0's — and item batching in a condition is legal inside a
target and MSB4191 during evaluation.

Pinned forward to 10.0.11 rather than back to 10.0.10, which would have been a
one-line change with no lock file churn. Holding the trimmer a release behind the
framework it analyses to dodge an error is how a missed trim warning happens, and
taking the newer one makes the bump a reviewed diff, which is what the gate was
asking for.

Verified against the SDK that broke it rather than only the one here:

  - sdk:10.0-alpine, 10.0.400, `dotnet restore DodoSSH.slnx --locked-mode` —
    exit 0. That is ci.yml's line, on CI's SDK.
  - the android workload on sdk:10.0-noble, 10.0.400, locked-mode restore of
    DodoSSH.Client.Android — exit 0. That is scripts/ci-android.sh's line.
  - locally on 10.0.302, the same locked-mode restore of the solution — exit 0.

One set of lock files satisfying both SDKs is the whole point of the pin, and the
third check is the one that demonstrates it.

Release build clean: 0 errors, and 0 IL-prefixed diagnostics from the newer
analyser on the two trimmable projects. 1,869 tests over 19 suites, none failing.

A caution for the next person, learned the hard way here: `--force-evaluate` on
Windows rewrites every lock file it touches with CRLF, and 23 of the 26 had no
content change at all. Only the three that really moved are in this commit.
2026-08-12 11:44:56 +02:00
jaap-jan b80bf23341 Merge pull request 'Stop one tab's status banner from speaking for all the others' (#10) from claude/status-bar-tab-isolation-caa52b into main
ci / build and test (push) Failing after 8s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Failing after 7s
Reviewed-on: #10
2026-08-12 09:37:55 +00:00
jaap-jan 8c58e5a558 Stop one tab's status banner from speaking for all the others
ci / build and test (pull_request) Failing after 10s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Failing after 6s
A shell that ended printed "The remote closed the session." into the status
banner at the foot of the terminal. Switch to a tab whose shell was still very
much alive and the sentence was still there, sitting under a live prompt and
describing a terminal that was no longer on screen.

There is one #status element for the whole page, because there is one page for
every terminal — the panes are stacked in the same box and all but the active one
are hidden — and SESSION_CLOSED wrote its reason straight into it. The other half
of the same mistake ran the other way: SESSION_OPENED and SESSION_REMOVED both
cleared the element outright, so opening or closing any tab wiped a message that
belonged to a different one. Whichever tab spoke last owned the banner.

The fix is to separate the two things that were being put in one place by who
they are actually true of. A session's last words are a fact about one terminal
and are now held on the session record, drawn only while that session's pane is
the one showing; activate() re-renders, so the banner follows the tab and a dead
tab still says what became of it when you come back to it. The socket's own state
— "Connecting…", "Reconnecting the terminal view…" — stays page-wide, because
there is a single socket behind every pane, and it wins when both have something
to say: a page whose socket is down is not showing live output on any pane.

A SESSION_CLOSED for a session this page has no pane for is now dropped rather
than printed. There is nothing to attach it to, and putting it in the banner
anyway is precisely the bug in miniature.

Verified by driving the real handleFrame through a stub DOM under node, which is
as close as this repo gets — there is no JS test harness and CI runs dotnet only,
so nothing here is a standing test. Twelve checks over open, close, switch,
reopen, remove and a socket drop pass against this file; the same script run
against the previous one reproduces the report exactly, epitaph under a live tab
included. Not seen in a running app: no C# changed, and the page is unreachable
without one.
2026-08-12 11:28:01 +02:00
jaap-jan cec73010d3 Colour the window's frame, inset Hosts like its neighbours, drop Pins
ci / android head (pull_request) Failing after 12s
ci / build and test (pull_request) Failing after 12s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
Three things one pass over the shell's chrome turned up, none of them related
to the others beyond having been looked at together.

◆ A PALE STRIP ACROSS THE TOP OF THE WINDOW ON WINDOWS, and it is not this
application's titlebar. Avalonia's Win32 backend gives a BorderOnly window
WS_BORDER | WS_THICKFRAME and then calls DwmExtendFrameIntoClientArea with
one-pixel margins on all four sides — read out of WindowImpl.UpdateWindowProperties
in 12.1.1 rather than guessed at. So DWM owns a hairline of every edge and fills
it with the system's caption and border colours, which follow the user's
personalisation settings: with "show accent colour on title bars and window
borders" on, that is blue against a near-black shell. Nothing in the visual tree
painted those pixels, which is why nothing in the visual tree could cover them.

NativeWindowFrame sets DWMWA_BORDER_COLOR and DWMWA_CAPTION_COLOR to the
window's own Background, so the hairline still exists — the resize grip is on
it, the drop shadow hangs off it — and cannot be seen. Deliberately not
DWMWA_COLOR_NONE, which removes the border outright and leaves a near-black
window with no edge at all on a dark desktop. Windows 10 gets the dark-mode
attribute and nothing else, because the two colour attributes are Windows 11
and DwmSetWindowAttribute simply answers E_INVALIDARG there.

Called from OnOpened, not the constructor: there is no platform handle until
the window is shown, and calling early is a silent no-op — which looks exactly
like a fix that does not work.

Verified on screen on Windows 11.

◆ THE HOSTS HEADER SAT A STEP LEFT OF AND ABOVE EVERY OTHER SCREEN'S. Keychain,
Snips, Logs and Pins all frame their content with Margin="26"; Hosts was on 16
a side and 20 on top. It is 26 all round now, stated per row rather than once on
the root, because the board's ScrollViewer is deliberately full-bleed so that
its scrollbar rides the pane's edge, and because a root margin would also inset
the drawer, which draws its own.

That cost the cards ten pixels, and the layout suite is what said so:
TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen failed, because
Border.tile's 224 was derived from the board's old 16-pixel margins and the grid
quietly collapses to one column at exactly the size this application guarantees.
224 becomes 214, with the arithmetic in App.axaml rewritten — it had also gone
stale in a way that hid itself, still citing the 1016 minimum and 190 rail from
before v5b, whose two changes happened to cancel.

◆ PINS LEAVES THE RAIL, and only the rail. KnownHostsScreen is still built and
still one click away, from "Host keys" on the Keys screen's own header, which
was always the second way in. The row was kept through v5b on the grounds that
the mock has no screen for approved host keys — a reason for the screen to
exist, and never a reason for a rail entry once the keychain had a door to the
same place. Two rows landing on one screen is a rail that has to be read twice.
MainWindowViewModel.IsKnownHostsShowing stays: it names a real shell state and
ShellFlowTests still asserts on it.

design-import-gaps.md recorded that row as a deliberate deviation and
manual-checks.md Phase 1.1 walked the rail entry by entry; both are corrected,
and the manual check now reaches the screen the way a user would.

The layout suite's rail row count moves from six to five with it.

153 layout tests and 446 shell tests pass. The frame is confirmed by eye; the
Hosts inset and the rail are covered by the layout suite but were not seen
running, because the instance launched to check them came up locked.
2026-08-12 10:43:37 +02:00
jaap-jan 53ff15ba86 Keep drawing the terminal after Android takes the GPU context away
ci / build and test (push) Failing after 33s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Failing after 4m12s
The phone's terminal was blank whenever it was connected. Not slow, not
mis-sized, not disconnected: a live session accepting keystrokes, acknowledging
output and drawing nothing at all.

◆ THE WEBGL ADDON DOES NOT RECOVER FROM A LOST CONTEXT AND DOES NOT FAIL LOUDLY.
It stays loaded over a dead context and renders an empty rectangle, which is
xterm's documented behaviour and the reason its guidance is to subscribe to
onContextLoss and dispose. This page never did, and until there was a phone
there was no reason to notice.

Losing the context is ordinary on Android and nearly unheard of on Windows,
which is what made this a one-head bug in shared code. Collapsing the renderer
sets the native view to GONE — Avalonia's AndroidNativeControlHostImpl.HideWithSize,
read out of the assembly rather than guessed at — and a WebView with no surface
has no GL context. The shell collapses it every time a tab starts connecting,
every time the connect sheet opens and every time the app is backgrounded. Worse,
the ordering guarantees it for the first session on every launch: OpenSessionAsync
sends SESSION_OPENED before the tab reports a session, so IsTerminalShowing is
still false and the pane, the terminal and its GL context are all built inside a
collapsed WebView. WebView2 hides a child HWND and keeps rendering throughout,
which docs/platform-flags.md measured at length.

The addon is not reloaded after a loss. A pane that lost the context once is on a
surface that will do it again, and thrashing between renderers is worse than being
slow — the DOM renderer is what the existing fallback comment already argues for,
because a blank pane is not usable and a slow one is.

The comment above MINIMUM_FITTABLE_PIXELS was wrong for this head and is corrected
with it. It asserted that collapsing the WebView leaves this page's viewport alone,
so no observer fires and the guard protects nothing — true of a hidden child HWND,
false of a GONE view, which its parent's layout skips outright. On the phone the
guard is the only thing standing between a lock, a connect sheet or a trip to the
background and a remote pty reflowed to 2x1.

Also on the way past: the renderer-timeout message told phone users to install the
Microsoft Edge WebView2 runtime. That is the other blank-terminal failure mode's
message, and naming a runtime that cannot exist on the device is worse than saying
nothing at the one moment somebody is trying to work out what went wrong. It now
names Android's own WebView on that head, as a runtime check for the reason
MainWindowViewModel.GestureWait records beside its own.

Not verified on a device — there is no handset or emulator here, and no test
covers this page. The diagnosis is the decompiled hide path plus xterm's own
requirement, not an observation. 523 tests over the shell and the terminal pass,
and both heads build.
2026-08-11 22:58:11 +02:00
jaap-jan 766fe6aebe Stop the test sshd penalising the suite for its own host-key refusals
The SSH suite has failed intermittently for months with SshConnectionException
"The connection was closed by the remote host", within milliseconds, on
whichever class happened to be running. Two previous attempts guessed at the
cause and said so honestly; this one has a mechanism and a before/after.

◆ THE CAUSE IS PerSourcePenalties, WHICH THIS SUITE PROVOKES BY DESIGN.

OpenSSH 9.8 added per-source penalties and 10.x enables them by default; the
image runs 10.3 and its config never mentions the keyword, so the compiled-in
default was what ran. A source address that repeatedly disconnects without
attempting authentication gets penalised, and while the penalty holds every
connection from it is answered with the clear-text line "Not allowed at this
time" and then closed.

That is exactly the traffic this suite generates. This client's first contact
with an unknown host is a connection deliberately refused at the host key —
a disconnect with no authentication attempt — so every helper that learns a
host key by being turned away first, plus RefusingTheHostKey_AbortsTheConnection
and AnUntrustedHost_IsRefusedExactlyAsAShellWouldBe, feeds the penalty counter.
Enough of them close together and sshd stops talking to the test host for a
while, then starts again.

Measured on a fresh container, probing 200 times with connections of that shape:
with the image default, the first refusal came back at probe 18 and 183 of the
200 were refused. With PerSourcePenalties no, none of 200 were. That is the
before/after the earlier attempts could not produce.

It also explains the shape of the failure, which never fitted a throttle. The
class that failed lost EVERY connection it made rather than a random few —
including the one test that expects a refusal, which passed throughout for the
wrong reason — while the classes around it were untouched. That is a window in
which the server refuses one source, not a probabilistic drop.

Both earlier diagnoses are recorded in the fixture so they are not tried again.
MaxStartups was blamed on the reasoning that xUnit runs test classes in
parallel, so ten unauthenticated connections would be in flight at once; but
every class touching this server shares one collection and xUnit parallelises
collections, not classes, so they run one after another and never have more than
a connection or two open. The reload window was blamed next, and a wait for the
banner was written and removed as unproven — it was unproven because the banner
answers perfectly right up until the penalty lands, so a check that stopped at
the first "SSH-" ran entirely inside the good part.

MaxStartups is kept, on the narrower argument that it is right regardless: a
connection throttle is hardening a test server has no business reproducing.
Removing it would be a second change riding along with this one.

The readiness gate that replaces the reconfigure's silence is a guard rather
than a wait. It requires 25 connections answered back to back, which is the
specific provocation rather than a soak test: 25 is above the measured
threshold of 18 on purpose, and it costs under a second when the setting is off.
Ten was tried first and was worse than useless — it sits below the threshold, so
it passed against a server that was still penalising. With the fix removed the
gate now fails in a minute naming PerSourcePenalties and quoting the server's
own "Not allowed at this time", instead of the suite failing later somewhere
unrelated.

The gate also closes a hole the container's own readiness cannot: a log line and
netstat showing :2222 both pass on a container whose sshd has gone, because
Docker publishes the port with a host-side proxy that accepts before it has
anything to forward to. It is probed from the host rather than with docker exec
for the same reason it matters — that is the path the tests take, and penalties
are counted per source address.

Rejected: patching sshd_config from /custom-cont-init.d to avoid the reload
entirely. It looks like the right hook and is not — the container's log puts
"sshd is listening on port 2222" before "[custom-init] Files found, executing",
so a script there edits a file the running server has already read. It leaves a
config that greps correctly and a server behaving as though it were never
touched, which is the same trap as patching the wrong one of the image's two
config files. Twenty-eight tests failed before that was noticed; the finding is
in the fixture.

Four consecutive full-solution runs clean, and the SSH suite green on every run
since. 1,861 tests, none failing.
2026-08-11 22:58:11 +02:00
jaap-jan 9f73893e14 Merge pull request 'Give the terminal back the width and the keyboard the session shell took' (#7) from claude/quick-access-terminal-fixes-565d4a into main
ci / build and test (push) Failing after 2m20s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m36s
Reviewed-on: #7
2026-08-10 14:52:22 +00:00
jaap-jan ccaf7a8e72 Give the terminal back the width and the keyboard the session shell took
ci / build and test (pull_request) Failing after 2m33s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m34s
Seven things reported from a day's use of v5b's session shell, and they are one
commit because five of them are the same complaint from different angles: the
window spends too much of itself on chrome describing the session, and the parts
that are not chrome do not behave.

◆ THE HOST HEADER IS GONE, and that is a deliberate departure from the design.

Terminal.dc.html and SFTP.dc.html both draw a 60-pixel row above the pane: the
address on the left, a cross-surface button on the right. Both facts are worth
having and the strip they sat in was not — the tab already names the host, and a
full-width bar repeating it was the cheapest 60 pixels in the layout to give
back. The address is now the first line of the sidebar and the button is
stretched across the column under it, so nothing is lost and the pane is taller.

Which word that button carries and which command it runs used to be handed in
from the two usage sites in MainWindow.axaml, because the control was drawn
twice. One sidebar cannot do that, so SessionCrossSurfaceLabel and
OpenOtherSurfaceCommand resolve it in the shell — the same place SessionAddress
already decides which surface's fact to read. The two directions underneath are
untouched: SelectFilesHostAsync for a tab's host, OpenTerminalForFilesHostAsync
for a fresh terminal at whatever SFTP has open.

SessionHeader.axaml is deleted rather than left unused, and LayoutHarness stops
subtracting its 60 pixels from every session screen's budget — the same
treatment the retired window-wide tab strip got, and for the same reason: a
constant for chrome nobody draws is a suite quietly measuring the wrong
rectangle.

◆ AND THE SIDEBAR CLOSES, which the design has no state for at all.

300 pixels of an 1081-pixel minimum is a great deal to spend on a list that is
often two rows long. The column now folds to a 34-pixel rail carrying the
chevron that brings it back — a rail rather than nothing, because a panel that
vanishes leaving no trace is one people report as lost rather than as closed.
Both states live in the one control and swap on IsSessionSidebarOpen, so
MainWindow's own "Auto" column takes whichever width is showing without knowing
the state exists.

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.

---- THE FOUR SMALLER ONES ----

A SNIP LANDED IN A TERMINAL NOBODY COULD TYPE AT, and looked selected when it
got there. Two causes with nothing in common. The click moved Win32 focus onto
the sidebar row, and term.focus() in the page cannot take it back — only the
host can, so the shell raises TerminalFocusRequested and the window answers with
the same posted focus every other path here uses. The highlight was bash: xterm
wraps a paste in bracketed-paste markers, readline marks what arrives inside
them as an active region, and it stays in reverse video until the next
keystroke. Right for a clipboard paste, wrong for a snippet picked off a
sidebar. Single-line snips are typed rather than pasted now, which needs no
markers; multi-line still pastes, because "runs three commands unasked" is the
worse of the two failures and the markers are the whole of what prevents it.

A BLACK BAR UNDER THE TERMINAL, on Windows. xterm.css paints its scrolling
viewport #000 — its own comment explains why, and it is a macOS scrollbar
concern. Everywhere else that black is covered by the rows, except along the
bottom: the fit addon floors the row count, so the remainder below the last
whole row is bare viewport, up to a line tall, against this page's #171a26. The
light square at its right-hand end is where WebView2's classic scrollbar corner
lands. The viewport is repainted in the page's own background, and the scrollbar
with it — thin and in these colours rather than a grey Windows channel down the
side of a near-black terminal, and kept rather than hidden, because a surface
that scrolls with no sign that it does is worse than a quiet bar.

THE PINS ROW DREW A TOFU BOX. U+E946 is not in the embedded Material Icons face
at all — that file is the 2019 build and its cmap skips E944 and E946 — so the
rail's Pins row and the hosts screen's own pin badge have both been drawing a
missing-glyph rectangle since v5b picked the codepoint. push_pin in that vintage
is U+F10D, verified against the file rather than against a codepoints table for
a later release of the font. Every other icon codepoint in the repository was
audited the same way; this was the only miss.

THE KBD CHIP CUT THE CHORD IN HALF. 34 pixels is the design's width for a chip
reading ⌘K, and this build substitutes CTRL K — six characters and a space,
wider than 34 at 10.5 mono. MinWidth and padding instead, so the design's
footprint survives for the day this face has a ⌘ to draw.

---- AND THE POPOVER UNDER THE USER CHIP ----

Reported as not matching the design, and it was not: Button.poprow set a corner
radius and a padding and never touched the Background, so every row wore the
Fluent theme's own #33FFFFFF button fill. Six raised pills stacked in a menu the
design draws as six lines of text — and the hover rule underneath was already
correct and simply invisible against a fill that never went away. Set on the
ContentPresenter as well as on the Button, the same as Button.flat, because the
theme binds its brush there and a Background set only on the control loses to
it. The panel itself gets this window's own radius-12 card treatment through a
FlyoutPresenter class rather than by widening the shared context-menu rule, and
Vaults and Preferences stop being drawn one step dimmer than Settings and
Logout, which read as two disabled entries in a menu of five live ones.

---- WHAT PROVES IT ----

Three tests in the layout suite, two of them checked against the defect they
describe: the popover row's resting fill (fails with #33ffffff without the
style), and the kbd chip against the natural width of its own text, measured on
a detached copy because a TextBlock's DesiredSize is already clipped to what it
was given and reports 34 inside a 34-pixel chip either way. SessionSidebarTests
is new — the sidebar has never been laid out by a test, and it now holds a
string of unbounded length beside a button that has to stay clickable. In the
shell suite: the cross-surface row in both directions, the closed state
surviving to disk, and the focus request being made when a snip lands and not
made when it does not.
2026-08-10 16:49:44 +02:00
jaap-jan 6fc1e3a7c5 Merge pull request 'Say how far a connection has got while it is still being made' (#6) from claude/connection-status-indicator-e52da7 into main
ci / build and test (push) Failing after 2m22s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m48s
Reviewed-on: #6
2026-08-10 13:53:06 +00:00
jaap-jan 8a77b7ca68 Say how far a connection has got while it is still being made
ci / build and test (pull_request) Failing after 2m34s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m28s
The connecting card set its status string once, when the tab was created, and
never touched it again. Every connection therefore looked identical from the
outside: one three seconds into a key exchange, one waiting out a fifteen-second
timeout against a machine that is asleep, and one that had hung all drew the
same "connecting…". The card now draws the five steps of getting there, each lit
at the moment the handshake reports reaching it, over an amber track that fills
as they finish.

◆ NOTHING ON THE LIST IS INVENTED. Every row changes state because a layer below
it said so, at the instant the thing it names actually began.

That is the whole reason it is worth showing, and it is why most of this commit
is plumbing rather than XAML: there was no progress reporting anywhere in the
stack to hook a step list onto, and a card animating plausible progress would
have been indistinguishable from one that had stopped receiving any.

SshConnectionPhase names four phases and deliberately not more. SSH.NET runs the
entire handshake inside one ConnectAsync and raises exactly one event from the
middle of it — HostKeyReceived, once the key exchange has produced a key to show
— so that event is the only interior moment there is to report. Everything
before it is Reaching and everything after it is Authenticating. A fifth phase
in that assembly would have to be a timer, so there is not one. OpeningShell is
reported by TerminalWorkspace instead, because that is where it happens: the
factory's work ends with an authenticated connection, and asking for a
pseudo-terminal on one is a separate round trip. The SFTP path passes null — a
second connection opened behind an already-open shell has nobody watching a step
list for it.

The card's fifth step, "Starting the terminal", is the renderer wait and lives
in the shell rather than in the SSH assembly, which has never heard of a
renderer. On the first connection after a cold start it is a real wait with a
real failure mode of its own — a missing WebView2 runtime — so a list that began
at "reaching the host" would leave the one wait most likely to hang unnamed.

Amber for the step in flight, and that follows the palette's rule rather than
bending it. Green is what is true and purple is what you can press; a step still
happening is neither, and it is exactly the caveat-worth-reading that amber
exists for. Steps behind it go green as they become true. Nothing animates,
which is the argument TransfersScreen.axaml already makes for its own track,
reaching a screen with far more reason to want a spinner: a spinner is furniture
invented to fill a state nobody measured, and these states are measured, so the
track fills to what has finished and then waits there.

A refusal keeps the step it stopped on, in red, with the ones behind it still
green. That is the half a progress bar could not do, and it is the difference
between "that host is not there" and "that host is there and would not have me"
— a question the reason sentence alone frequently does not settle.

The strip's dot goes amber while a tab is connecting, on both heads. It was
grey, and so is a tab whose shell has exited: the two states in that strip with
the least in common, one worth waiting for and one over. PhoneShell's own
comment already recorded half of this — the dot stopped being green before
anything had answered — and this is the other half.

Progress is raised inline rather than through System.Progress<T>, which captures
whatever synchronisation context it was constructed on and posts to it. That
reads like a convenience and is really a second place the marshalling decision
gets made: silently, differently under a test with no context, and out of order
with respect to the failure that follows a phase. The shell marshals once, in
one handler, through a new optional post parameter on MainWindowViewModel — the
same seam TransfersViewModel already uses, and for the reason its own remark
gives. The three Dispatcher.UIThread.Post calls that predate it are the ones
this suite's comments record as out of reach; they are left alone rather than
swept in here.

Both heads draw the list. They differ in one place: Phone.axaml's mono class
sets a colour and a size along with the family, so the caption rule names its
own family instead of composing the two and asking two rules for one Foreground.
The desktop's mono sets the family alone, which is why ConnectingCard does
compose them. Each head also gains SHOW LOGS beside the button that gives up —
the step list is this attempt and the log is every other one, which is what a
connection taking too long actually raises.

Seven tests, and the two that matter most run against the container rather than
a fake: a real handshake reports its phases in order, and a host-key refusal
never claims to have authenticated. A fake asserting what it was written to
assert would have established nothing about either. The rest cover the tab
advancing while the connection is gated, the step a refusal stops on, and a
phase reported after the user has given up on the tab. 1,861 tests, none
failing.

The Android head's layout is not verified by anything. It compiles, and
compiled bindings mean every new binding path resolves, but that project is not
in DodoSSH.slnx, there is no test project for it and no device here — so unlike
the desktop card, whose shapes the layout harness measures, these rows have not
been drawn. Vertical fit is reasoned, not observed.
2026-08-10 15:47:45 +02:00
jaap-jan 9755b5f6ae Merge pull request 'Stop the SSH suite's server refusing connections at random' (#4) from claude/ssh-fixture-hup-race into main
ci / build and test (push) Successful in 2m24s
ci / android head (push) Successful in 3m17s
ci / desktop nightly (push) Successful in 41s
ci / api image (push) Successful in 23s
Reviewed-on: #4
2026-08-10 09:56:28 +00:00
jaap-jan afc6a042f1 Merge pull request 'Let the host editor make the credential it is about to bind' (#5) from claude/host-credentials-saved-sets-1786d4 into main
ci / build and test (push) Canceled after 0s
ci / android head (push) Canceled after 0s
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
Reviewed-on: #5
2026-08-10 09:56:22 +00:00
jaap-jan e41eca01a8 Stop the SSH suite's server refusing connections at random
ci / build and test (pull_request) Successful in 2m21s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m20s
ci / api image (pull_request) Successful in 4s
The suite fails intermittently with SshConnectionException "The connection
was closed by the remote host", within tens of milliseconds, on whichever
test happens to connect first. It has been seen in CI and reproduces
locally. This raises sshd's MaxStartups in the fixture, which is the most
likely cause and is worth doing regardless.

sshd's compiled-in default is 10:30:100: past ten unauthenticated
connections in flight it refuses new ones at random, thirty percent of the
time, rising to always at a hundred. The image ships the line commented
out, so that default was what ran. xUnit runs test classes in parallel and
most of the classes here open a connection, so ten in flight is reachable
during the opening seconds — and a refusal presents to the client exactly
as observed, because a dropped connection and a server that never answered
are indistinguishable from that end.

◆ IT IS A MITIGATION AND NOT A DEMONSTRATED CURE, AND THE COMMENT SAYS SO.

The flake rate could not be measured. On the Windows development machine
the identical unmodified suite ran 85/85 clean and, an hour later, failed
13 runs out of 15; a Linux container gave 30/30 clean and then failed on
the first run of the next batch. Docker throughput on that host swings far
enough to swamp the effect, so every before/after comparison taken there
was noise — including two that were briefly believed.

It is committed on the narrower argument that it is right either way. A
connection throttle is hardening this suite has no interest in
reproducing: it exists to test an SSH client, not to survive a rate limit,
and a test server that drops connections at random is a bad test server
whether or not it is the cause of this particular flake.

The other candidate was the reload window — pkill returns when SIGHUP is
delivered, not when sshd has finished closing its listeners and re-execing,
so a connection immediately afterwards can be refused the same way. A wait
that required three consecutive banner reads before returning was written
and then removed: it could not be shown to change anything either, and a
fixture carrying two unproven fixes for one symptom is worse than one,
because the next person has to disprove both. Both candidates, and how to
tell them apart with sshd's own log, are recorded in the fixture and in
docs/platform-flags.md.
2026-08-10 11:46:14 +02:00
jaap-jan e96d01aab9 Let the host editor make the credential it is about to bind
ci / build and test (pull_request) Successful in 2m12s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m18s
ci / api image (pull_request) Successful in 4s
The authentication picker has listed saved credentials since they existed, but
making one meant leaving a half-typed host for the keychain screen and coming
back to find it gone. On the phone it was worse than a detour: that head has no
credential editor at all, so it could bind a host to a credential and never
produce one. + NEW CREDENTIAL opens a card under the picker — name, optional
username, password, notes — and ADD writes it and binds the host in one step.

A button beside the picker rather than an entry inside it. Every row of that
list is a binding a host can have, and "make a new one" is an action: as an
entry it would sit in the box afterwards describing a state no host can be in,
and cancelling the form would leave the picker showing it.

It carries its own five fields rather than reusing the keychain editor's, and
that is the load-bearing part. IsEditingCredential is what AVaultEditorIsInTheWay
asks about, so sharing it would have made the whole Vault screen refuse to open
an editor while this card sat open on the Hosts screen, with a status line
naming a form the user cannot see on a screen they are not looking at — the
exact failure that guard was split in two to end. A test pins it.

It writes to the keychain immediately, unlike every other field in this editor,
because a credential is a shared item with an id and a host can only name an id
that exists. The consequence is honest rather than hidden and the hint says so:
a credential added this way outlives a cancelled host edit. What was still being
typed does not — every path that closes the host editor clears the form, and one
of those fields is a password.

The binding is written before the reload rather than after it. RefreshOpenEditors
rebuilds this picker and then restores it from the editor's own selection, so
setting it first is what survives the pass, and by the time it is read
ReloadCredentialsAsync has put the matching entry in the list to land on.

A name already taken is duplicated, not reused, and that is a deliberate parting
from the new-tag box six lines further down which offers the existing tag
instead. Two tags called "staging" are one intention spelled twice; two
credentials called "root" are two different passwords, and quietly binding the
host to whichever was there already would authenticate it as an account nobody
chose. A duplicate label in the picker is the smaller problem.

Into editingHostVaultId, so the credential lands wherever the host is being
sealed and everybody who can read the host can read what it authenticates with.
Stricter than the tag path — which files into the active vault and is recorded
as a gap in docs/design-import-gaps.md — and it can be, because this picker
lists credentials from every readable vault rather than one.

Five flow tests cover the bind-through-reload path, the cancel semantics on both
the saved credential and the abandoned one, the cross-screen guard, the
duplicate name and the empty-password refusal. The layout test is separate and
necessary: the card is collapsed until somebody presses the button, so a harness
driven by the default state draws none of it, and TheHostDrawerFitsWithTheHostEditorOpen
would have gone on passing over a card that blew the column. 1,854 tests, none
failing.
2026-08-10 11:23:10 +02:00
173 changed files with 24970 additions and 1441 deletions

No files matched your search

+7 -1
View File
@@ -20,8 +20,14 @@
*.sql text
# Files that must keep LF regardless of platform — shell scripts run in Linux
# containers, and Dockerfiles are read by a Linux daemon.
# containers, Dockerfiles are read by a Linux daemon, and an RPM spec is a shell
# script in disguise: rpmbuild feeds its %install section to /bin/sh, where a
# carriage return at the end of a line is an argument rather than nothing. A udev
# rule is parsed by udev's own lexer, which treats a stray carriage return as part
# of the value it is reading.
*.sh text eol=lf
*.spec text eol=lf
*.rules text eol=lf
Dockerfile text eol=lf
*.Dockerfile text eol=lf
Caddyfile text eol=lf
+174 -3
View File
@@ -29,6 +29,18 @@ jobs:
build:
name: build and test
runs-on: [linux]
# ◆ A CEILING, BECAUSE THIS FILE HAD NONE AND A HUNG TEST RAN UNTIL SOMEBODY NOTICED.
#
# One test in DodoSSH.Client.App.Tests waited on a named pipe that, on Linux, nothing was ever going
# to answer — and it waited on a token with no timeout, so the job did not fail, it sat there. It was
# killed by hand after twenty minutes, having reported nothing but the eighteen suites that had
# already passed.
#
# Twenty-five minutes against a job that ordinarily takes three. It is not a performance budget and
# must not be read as one: a build that legitimately grows past this should have the number raised.
# What it buys is that the failure mode is a red job with a named step rather than a runner somebody
# has to go and stop.
timeout-minutes: 25
steps:
# act_runner runs every `uses:` action with node inside the job container, and this
# runner's image has none — the run died on the first line of actions/checkout with
@@ -220,7 +232,12 @@ jobs:
- name: build
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
# Tighter than the job's own ceiling, and deliberately so: a hang here is the likeliest of the
# lot — it is the only step running code written for this repository rather than a tool — and a
# step-level timeout names the step in the failure instead of leaving a reader to work out which
# of fifteen was still running. Ten minutes against a suite that takes two.
- name: test
timeout-minutes: 10
run: dotnet test DodoSSH.slnx --no-build --configuration Release
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
@@ -462,6 +479,55 @@ jobs:
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
# ◆ AND THE THIRD DESKTOP PLATFORM, WHICH IS THE ONE THIS RUNNER ACTUALLY IS.
#
# Same argument as the two steps above and with none of their cross-compiling: this job runs on
# Linux, so the linux-x64 publish here is a native one. What it proves that the others cannot is
# the linux-x64 restore graph — libsodium, SkiaSharp and HarfBuzzSharp all ship native assets per
# RID, and a package that resolves for win-x64 and has no linux-x64 asset fails here.
#
# The runner is still Alpine and the package targets glibc distributions, which is fine and is not
# glossed over: scripts/ci-linux-rpm.sh explains why rpmbuild composing an RPM for one libc on a
# host running another is correct rather than lucky.
#
# ◆ IT IS ALSO WHERE THE PACKAGING ITSELF IS PROVED, WHICH ON THIS PLATFORM IS NOT A FORMALITY.
#
# The Windows and macOS steps hand a directory to vpk and vpk decides what the package needs.
# There is no vpk here — Velopack's Linux target is an AppImage and this is a distribution
# package, updated by dnf rather than by the application — so the dependency metadata is
# generated by rpm from the published tree, and scripts/ci-linux-rpm.sh interrogates the result
# rather than trusting an exit code. Every check in there is a way a package can be built
# successfully and then refuse to install on every host in the world; one of them was written
# because .NET's own libmscordbi.so links libmscordaccore.so, which is exactly that.
#
# Release-shaped rather than nightly-shaped — package name dodossh, tree at /opt/DodoSSH — so
# that what is proved on every main build is the packaging the release channel would use. The
# desktop job below builds the nightly-shaped one and is the only job that publishes either.
#
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
#
# RUNNER_TEMP, dying with the job. An RPM is if anything the easiest of the three to pass on
# casually — one file, no installer to click through, `dnf install` and done — which is a reason
# to keep it off a downloads page rather than a reason to put it on one. ADR 0013 rule 3.
- name: publish and package the linux desktop client
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
# RestoreLockedMode=false for the RID, exactly as the two publishes above do — see the long
# note on the win-x64 one. This checkout is thrown away, so the lock files it rewrites go
# nowhere.
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
-p:RestoreLockedMode=false \
--output "$RUNNER_TEMP/linux-x64"
scripts/ci-linux-rpm.sh \
--payload "$RUNNER_TEMP/linux-x64" \
--version '${{ steps.winpack.outputs.packVersion }}' \
--channel release \
--out "$RUNNER_TEMP/linux-rpm"
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
# server through Testcontainers and runs the API as a child process — so it needs a
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
@@ -507,6 +573,8 @@ jobs:
exit 0
android:
name: android head
# See the build job. The docker pull and the toolchain image make this one legitimately slower.
timeout-minutes: 40
runs-on: [linux]
# Writes the nightly release at the end of the job; see that step for what the capability is and why
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
@@ -866,7 +934,10 @@ jobs:
# never made.
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
for name in $names; do
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
# grep without -q, which has the same exit status and reads to the end of its input. A
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
echo "The release was created but $name is not on it:" >&2
printf '%s\n' "$published" >&2
exit 1
@@ -877,6 +948,8 @@ jobs:
desktop:
name: desktop nightly
# See the build job. Two publishes, a pack and an upload of about 120 MB.
timeout-minutes: 40
# Gated on the tests, like the image job and for a stronger version of its reason: this one is
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
# commit whose suite was red.
@@ -1048,6 +1121,45 @@ jobs:
ls -la "$RUNNER_TEMP/nightly-releases"
# ◆ AND THE LINUX NIGHTLY, WHICH IS A PACKAGE RATHER THAN A FEED.
#
# Everything above this step is Velopack: an installer, an index file and a client that polls it.
# This one is an RPM, and the difference is not a gap in the work. VelopackUpdateChannel refuses
# every platform that is not Windows or macOS — deliberately, and its comment says why — so a
# Linux build has no self-updater to give a channel to. What it has instead is dnf, which is a
# mechanism the user already trusts and already controls, and build/linux/dodossh.spec argues
# that at length.
#
# So there is no rolling *feed* here, only a rolling *file*: the newest nightly RPM sits on the
# same release as the Windows nightly and somebody who wants it downloads it. That is a worse
# deal than the Windows nightly gets and it is the honest one — the alternative is a package
# repository, which is a deployment this project does not have.
#
# DodoChannel=nightly for the same reason the win-x64 publish above sets it: it is what
# ClientPaths reads to suffix the profile directory, so the nightly RPM and a release RPM
# installed beside it share no cache, no outbox and no device key. The package name, the tree
# under /opt and the desktop entry are the packaging half of that separation and are the
# script's `--channel nightly` — see the note there for why it is four things and not one.
- name: publish and package the linux nightly
id: linuxpack
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
-p:RestoreLockedMode=false \
-p:DodoChannel=nightly \
-p:MinVerVersionOverride="$VERSION" \
--output "$RUNNER_TEMP/nightly-linux-x64"
scripts/ci-linux-rpm.sh \
--payload "$RUNNER_TEMP/nightly-linux-x64" \
--version "$VERSION" \
--channel nightly \
--out "$RUNNER_TEMP/nightly-rpm"
# ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
#
# Whoever can write a release here can put a build on every nightly desktop, because Velopack fetches
@@ -1138,17 +1250,76 @@ jobs:
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
# grep without -q, which has the same exit status and reads to the end of its input. A
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
echo "The release was created but $name is not on it:" >&2
printf '%s\n' "$published" >&2
exit 1
fi
done
echo "Published nightly desktop $VERSION as $TAG"
# ◆ THE RPM GOES ONTO THE RELEASE vpk JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
#
# One rolling release carrying both heads, because they are one nightly: the same commit,
# the same version, and one page a person looks at to find out what is on main. A second tag
# would double the delete-and-recreate dance this step already does, and would let the two
# drift apart on a run where one of them failed.
#
# It has to happen here, after `vpk upload`, and not in a step before it. vpk deletes and
# recreates this release, so an RPM uploaded first would be attached to something that no
# longer exists by the time the job ends — silently, because the delete succeeds.
#
# curl rather than vpk, unlike the assets above, for the reason the android job gives about
# its APK: this is one file the release simply carries. There is no index to write and no
# format that belongs to a tool.
release="$(printf '%s' "$published" | release_id)"
if [ -z "$release" ]; then
echo "The release was published and its id could not be read back:" >&2
printf '%s\n' "$published" >&2
exit 1
fi
# ◆ THE PACKAGE FIRST AND THE MANIFEST LAST, WHICH IS THE ORDER THE CLIENT DEPENDS ON.
#
# LinuxUpdateChannel reads linux-<channel>.json and then fetches what it names, exactly as the
# phone reads android-nightly.json. A manifest published before its package is a window in
# which every installed client is told to download something that is not there yet — and the
# client's answer to a missing asset is to throw, which a pressed CHECK NOW reports as a
# failure. Same rule, same reason, and the android job's comment says it first.
rpm='${{ steps.linuxpack.outputs.rpm }}'
manifest='${{ steps.linuxpack.outputs.manifest }}'
linuxNames=""
for file in "$rpm" "$manifest"; do
name="$(basename "$file")"
echo "Uploading $name ($(wc -c < "$file") bytes)"
curl -fsS -X POST -H "$auth" \
-F "attachment=@$file" \
"$api/releases/$release/assets?name=$name" >/dev/null
linuxNames="$linuxNames $name"
done
# Asked for back, exactly as the Velopack assets above are and for the same history: the
# android job once created a release, uploaded nothing, and reported success for every
# upload it never made.
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
for name in $linuxNames; do
# grep without -q; see the sibling loops above and scripts/ci-linux-rpm.sh.
if ! printf '%s' "$withRpm" | grep -F "\"name\":\"$name\"" >/dev/null; then
echo "The release was created but $name is not on it:" >&2
printf '%s\n' "$withRpm" >&2
exit 1
fi
done
echo "Published nightly desktop $VERSION as $TAG, with$linuxNames"
image:
name: api image
# See the build job. A cold layer cache is the slow case here.
timeout-minutes: 40
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
# clock on every main commit and buys the thing worth having: no image reaches the
# registry from a commit whose tests were red. An image is not a build artefact anyone
+68
View File
@@ -0,0 +1,68 @@
<Project>
<!--
◆ THE TRIMMER'S VERSION IS PINNED HERE BECAUSE OTHERWISE THE LOCK FILES ARE NOT LOCKED.
Microsoft.NET.ILLink.Tasks is not referenced by anything in this repository. The SDK adds it
on its own to any project that sets IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
DodoSSH.Crypto do, and the Android head gets it from trimming being on by default there — and
the version it asks for is whatever the running SDK happens to bundle. That version lives in
the SDK's own Microsoft.NETCoreSdk.BundledVersions.props, as a KnownILLinkPack item.
Which makes it a dependency whose version is a property of the toolchain rather than of this
repository, and that is the whole problem: packages.lock.json records it as a Direct reference
with a requested range, so the lock file silently means "whichever SDK last ran a restore".
global.json says rollForward: latestMinor, so CI's setup-dotnet installs the newest 10.x SDK
that exists on the day it runs. The moment .NET ships a servicing release, CI's SDK asks for a
version the committed lock files do not have, and the locked-mode restore in ci.yml fails with
NU1004 before a single file is compiled.
That is not hypothetical. It closed the whole pipeline: main's run 125 and every open pull
request went red together, on
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
from [10.0.10, ) to [10.0.11, ).
with nothing in any of those commits touching a package. .NET had shipped SDK 10.0.400, which
bundles ILLink 10.0.11 where 10.0.302 bundled 10.0.10, and setup-dotnet installed it the next
time anything ran.
Worse than the outage is the shape of the repair without this pin. Regenerating the lock files
holds only until the next servicing release, and it cannot be done from a machine whose newest
SDK is older than the runner's: a restore on 10.0.302 writes 10.0.10 straight back and re-breaks
CI, so the recorded version becomes a fact about whoever ran restore last rather than about this
repository. That is exactly the state locking exists to prevent, and it is not a hypothetical
either — every SDK installed on the machine this pin was written on tops out at 10.0.302.
Pinning it makes the recorded version a decision this repository made, reviewable in a diff
like every other version in Directory.Packages.props, and identical on every machine whatever
SDK it has. Moving it is then a deliberate edit here plus a regenerated lock file, which is the
same ceremony any other dependency bump gets.
It is an Update on the SDK's item rather than a PackageVersion in Directory.Packages.props, and
it has to be: the reference is implicit, so the SDK supplies the version itself and central
package management never gets asked. ProcessFrameworkReferences reads @(KnownILLinkPack) when
it runs, which is why this lives in Directory.Build.targets — the item does not exist yet while
Directory.Build.props is being evaluated.
Keep this within a patch or two of the runtime the SDK ships. It is the trimming analyzer and
the ILLink task, so a small skew is harmless, but a version far behind the framework being
analysed is a real way to miss a trim warning.
-->
<Target Name="PinTheILLinkPackVersion" BeforeTargets="ProcessFrameworkReferences">
<!--
Inside a target, and not for tidiness. The SDK ships one KnownILLinkPack per target framework
and they all share the identity "Microsoft.NET.ILLink.Tasks", so the TargetFramework metadata
is the only thing telling net10.0's entry from net8.0's. A condition on %(...) is item
batching, which MSBuild permits in a target and rejects during evaluation with MSB4191 — so
an ItemGroup at the top of this file cannot express "only the net10.0 one" at all, and the
unconditioned Update it would have to become rewrites every framework's entry.
-->
<ItemGroup>
<KnownILLinkPack Update="Microsoft.NET.ILLink.Tasks"
Condition="'%(TargetFramework)' == 'net10.0'"
ILLinkPackVersion="10.0.11" />
</ItemGroup>
</Target>
</Project>
+50 -3
View File
@@ -88,8 +88,12 @@
-->
<PackageVersion Include="NSec.Cryptography" Version="26.4.0" />
<PackageVersion Include="libsodium" Version="1.0.22" />
<!-- Managed differential oracle for the crypto test suite only. -->
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
<!--
Managed differential oracle for the crypto test suite only. Also what SSH.NET pulls in,
so central transitive pinning makes this pin its floor too: 2.7.0 is what SSH.NET
2026.0.0 asks for, and pinning below that is a downgrade error rather than a preference.
-->
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
</ItemGroup>
<ItemGroup Label="Desktop client">
<!--
@@ -98,8 +102,14 @@
agent forwarding (needs an upstream change; de-scoped from v1) and being handed a
pre-connected Stream — it performs its own socket connect, which is why the relay and
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
2026.0.0 is the fix for GHSA-q939-rpr3-3284, a path traversal in ScpClient.Download's
recursive mode that trusts server-supplied names (everything up to and including
2025.1.0 is affected). Nothing here uses ScpClient — transfers go through SftpClient —
so this was an upgrade on principle rather than an exposure, and the release notes
list no breaking changes over 2025.1.0.
-->
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
<!--
The S3 client, for buckets as a remote in the file browser. First-party, Apache-2.0, and
managed only — no native assets — which is the bar this file sets for anything that gets
@@ -163,6 +173,28 @@
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
-->
<PackageVersion Include="Velopack" Version="1.2.0" />
<!--
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
Windows needs nothing here: CNG is in the framework and the Platform Crypto Provider is the TPM
behind it, and macOS reaches the Secure Enclave through the Security framework that
MacSymbols.cs binds by hand. Linux has neither — the platform's own stack is tpm2-tss, a C
library with a large surface — so this is the one head that needs a package to talk to its own
hardware.
Chosen over P/Invoking libtss2-esys for two reasons that both hold at the same time. It is
managed all the way down, so there is no native dependency to add to the RPM and nothing to
resolve at load time; and it works against a software TPM over a socket exactly as it does
against /dev/tpmrm0, which is what makes LinuxDeviceKeyStoreTests able to run the whole seal,
unseal and lockout path unattended. Neither of the other two device key stores can be tested at
all — see WindowsDeviceKeyStoreTests for the ten minutes that took to find out.
It carries a runtimes/linux-x64 asset, like SkiaSharp and libsodium, so the RID-specific publish
picks it up and the RID-free locked restore does not. See the RuntimeIdentifiers note in
DodoSSH.Client.App.csproj for why that distinction is load-bearing here.
-->
<PackageVersion Include="Microsoft.TSS" Version="2.1.1" />
</ItemGroup>
<ItemGroup Label="Versioning">
<!--
@@ -211,5 +243,20 @@
mistakes that cause real authorization holes.
-->
<PackageVersion Include="WireMock.Net" Version="2.13.0" />
<!--
A JavaScript interpreter, in managed code, so that terminal.js can be tested as the file that
ships rather than as a transcription of it into C#. Test-only and referenced by exactly one
project; nothing in src depends on it.
Chosen over shelling out to node, which is the obvious alternative and needs node present
wherever the suite runs — CI installs one already, but a test that only runs when a separate
command is remembered is a test that stops being run. Chosen over a headless browser for the
same reason several times over.
What it does not buy: Jint is not Chromium, so this proves the page's own logic and nothing
about how WebView2 or Android's WebView behave. That line is drawn in RendererPage's remark and
picked up by docs/manual-checks.md 1.10 and 11.12a.
-->
<PackageVersion Include="Jint" Version="4.16.0" />
</ItemGroup>
</Project>
+109 -5
View File
@@ -69,9 +69,10 @@ src/
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked
DodoSSH.Client.Import reading ~/.ssh/config and the keys it names when asked; reading a Hetzner
Cloud project over its API
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
DodoSSH.Client.App the desktop head: its views, and its Windows integration
DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
DodoSSH.Client.Android the phone head: its views, and its Android integration
tests/ one test project per source project
docs/adr/ architecture decision records
@@ -81,7 +82,9 @@ docs/manual-checks.md what no test can reach, and what to look for when che
docs/android-port.md the Android head: what was decided, what is built, what is left
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
release-macos.sh — the same, signed and notarized, on a Mac
ci-linux-rpm.sh — builds the Linux .rpm, then interrogates it
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
build/linux/ the RPM spec the Linux package is built from
```
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
@@ -177,6 +180,74 @@ passphrase screen, always. The application lives at `/Applications/DodoSSH.Deskt
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
first never touches the second.
## Installing on Linux
An `.rpm` for x86_64, and the one platform where you get it from `main` rather than from a release — see
[the nightly build](#the-nightly-desktop-build) below, which is where the Linux package is published today.
```bash
sudo dnf install ./dodossh-nightly-*.x86_64.rpm
```
Everything above about where a client may come from applies unchanged. Four things differ, and the first
two are the ones to read before installing.
**It checks for updates and it installs nothing.** The Windows and macOS builds carry Velopack and replace
their own files; this one never does. It checks the project's forge every six hours, and when there is a
newer build it downloads it, checks its digest, and hands the file to **PackageKit** — which raises the
usual "authentication is required to install software" prompt. You may say no, and nothing is replaced until
you say yes. Nothing restarts either: replacing files under a running process is ordinary here, so the build
you have keeps running until you close it.
That split is the point rather than a compromise. Installing software is your package manager's job and it
already answers to you; noticing that a build exists is the part `dnf` cannot do for a package that is not
in a repository. What DodoSSH must never become is a second thing that can put software on your machine on
somebody else's say-so, which is why the address it checks is a constant in the binary and can never be a
DodoSSH server — [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) rules 4 and 11.
The gap that remains: there is **no package repository**, so `dnf upgrade` alone will never bring you a
newer DodoSSH. If you turn the check off on PREFERENCES → UPDATES, nothing else will tell you.
**Your PIN can stand in for your passphrase, if this machine has a TPM you can reach.** The device key is
sealed into the TPM and released only against a PIN that **the TPM itself checks** — it counts wrong answers
and stops responding after a few, which is what makes four digits enough and what makes a stolen disk
useless. Two honest caveats, both in
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4:
- **It is weaker than Windows Hello or Touch ID**, and the difference does not show. Those draw their prompt
from inside the operating system; this one is a window DodoSSH draws, so something already running as you
could imitate it. It could imitate the passphrase screen too, which is worth far more — but the guarantee
is not the same and this page will not pretend it is.
- **The package grants your session access to the TPM, and you should know what that means.** Fedora leaves
`/dev/tpmrm0` readable only by the `tss` group, so this would otherwise need an administrator and a fresh
login. Instead the package installs a udev rule tagging the device `uaccess` — the same mechanism your
webcam and your FIDO key already use, which grants an ACL to **whoever is logged in at the machine** and
takes it away when they log out. Not over SSH, not to a user switched away on another VT, not to daemons.
The cost is not secrecy — TPM objects are protected by their own PINs — it is that anyone at the keyboard
can make failed attempts, and a TPM answers enough of those by locking out *everyone*. **If this machine
unlocks its disk with the TPM at boot**, that means somebody sitting at it could stop the next boot from
unlocking until the lockout recovers. If you would rather not have that, one command puts it back:
```bash
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
```
DodoSSH then behaves as though the machine had no TPM: no PIN offered, passphrase every time.
See [docs/unlocking-without-the-passphrase.md](docs/unlocking-without-the-passphrase.md).
**It needs WebKitGTK, because the terminal is a WebView.** Specifically `webkit2gtk4.1`, which `dnf` pulls
in for you. Fedora 44 is the distribution the head has actually been run on. RHEL 9 and its rebuilds ship
the older 4.0 API line under a different library name and the package deliberately does not claim them —
`dnf` will refuse with *nothing provides libwebkit2gtk-4.1.so.0*, which is a better answer than installing
and drawing a blank terminal. This project does not ship desktop builds nobody has run, on any platform.
**Where things go.** The application at `/opt/DodoSSH`, launched as `dodossh` or from your menu; your vault
cache at `$XDG_DATA_HOME/dodossh`, usually `~/.local/share/dodossh`. Two different places on purpose:
`dnf remove` takes the first and never touches the second, so reinstalling asks for your passphrase rather
than starting over. Use **Sign out** inside the application to make the machine genuinely forget.
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
not only that the runners are Linux.
@@ -189,6 +260,18 @@ Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\Dodo
costs nothing you are relying on. It signs in separately and appears to your account as a new device,
because it genuinely is a second installation.
**The Linux `.rpm` is on the same release**, built from the same commit and versioned the same way, as
`dodossh-nightly`. It installs beside a release package for the same reasons and by the same four
separations — a different package name, a tree at `/opt/DodoSSH.Nightly`, a `dodossh-nightly` launcher with
its own menu entry, and a profile at `~/.local/share/dodossh.nightly`. Two RPMs owning different paths
cannot replace one another, so `dnf` will keep both rather than treating one as an upgrade of the other.
It is the only Linux package this project publishes at all today: there is no `v*`-tagged Linux release
yet, because no person has walked a Linux build through
[docs/manual-checks.md](docs/manual-checks.md). What CI proves on every push to `main` is that the release
package still *builds* — the same arrangement Windows and macOS have had since the beginning, where the
package is built on every commit and uploaded nowhere.
**Installing it means trusting everyone who can write a release on this repository, including CI.** The
updater applies what the feed serves without verifying a signature, so anyone who can change a workflow
file here can put a build on every nightly machine. That is an acceptable trade for a build you are trying
@@ -920,10 +1003,31 @@ keychain plus a terminal — and the spike that gates all of it.
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
[Installing on macOS](#installing-on-macos).
**The Linux half checks, and the package manager installs.** An `.rpm` for x86_64, built and checked on
every push to `main` and published to the nightly release, installing to `/opt` with a menu entry and a
`dodossh` launcher. It carries no Velopack and replaces nothing itself: it notices a newer build on the
project's forge, downloads it, verifies its digest and hands the file to PackageKit, which asks before it
installs. Splitting it that way keeps the part that can put software on your machine where it already
was — with your package manager — while fixing the half `dnf` cannot do for a package that is in no
repository. The device key is sealed into the TPM behind a PIN **the TPM checks and counts failures
against**, which is what makes four digits enough. Reaching the TPM needs no administrator and no group:
the package installs a udev rule tagging `/dev/tpmrm0` with `uaccess`, so the ACL follows whoever is
logged in at the machine and goes away with them — the same mechanism a webcam uses. What that costs, and
the one symlink that undoes it, is in the rule's own comments. See
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4,
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 11 and
[Installing on Linux](#installing-on-linux).
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the
manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
everything above is verified only as far as the bundle.
certificate is the first release aimed at strangers), macOS on Intel, a `v*`-tagged Linux release and a
signed package repository to serve it from, and Phases 18 and 19 of the manual checks — neither the macOS
nor the Linux build has been walked through by a person. There is no macOS runner in CI at all. The Linux
package is verified as far as what it *says* about itself — CI builds it and interrogates its
dependencies, its file list and its modes, and installs nothing — and the two pieces of Linux behaviour
that are code rather than packaging do have tests: the update channel's version comparison and digest
check run in CI, and the TPM store's seal, unseal, wrong-PIN and lockout paths run against a software TPM
anybody can start with one `docker run`. What no runner covers is `/dev/tpmrm0`, PackageKit and polkit,
which is what Phase 19 is for.
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
## Licence
+54
View File
@@ -0,0 +1,54 @@
# Lets the person sitting at this machine use its TPM, without joining a group.
#
# ◆ WHAT THIS CHANGES, AND WHAT IT DELIBERATELY DOES NOT.
#
# Fedora's 60-tpm-udev.rules leaves /dev/tpmrm0 as root:tss mode 0660, so using it means being in
# the tss group — which means an administrator, a usermod and a fresh login before DodoSSH will
# offer to seal a device key. This adds the uaccess tag, and nothing else: the mode and the group
# are untouched, so everything that worked before still works and an ACL is layered on top.
#
# uaccess is systemd's own mechanism for exactly this question and is what /dev/dri, webcams,
# scanners and FIDO keys already use. logind grants a POSIX ACL to the user of the *active local
# session* and takes it away again when that session ends. So:
#
# - somebody logged in at the physical machine gets access;
# - somebody logged in over SSH does not;
# - a user switched away to another VT does not;
# - a system daemon running as its own user does not.
#
# That is a much narrower grant than adding a person to tss, which is permanent, applies over SSH,
# and applies whether or not they are the one at the keyboard.
#
# ◆ tpmrm AND NOT tpm, WHICH IS THE WHOLE REASON THIS IS DEFENSIBLE.
#
# /dev/tpm0 is the raw device and access to it is exclusive — two users cannot share it, and a
# process holding it can leave the chip in a state that breaks everything else. /dev/tpmrm0 is the
# kernel's resource manager: it virtualises handles, swaps contexts and isolates callers from each
# other, and it exists *because* the TPM is meant to be usable by more than one unprivileged
# program. Granting a seat's user the manager is the use it was built for. Granting the raw device
# would not be, and this rule never names it.
#
# ◆ THE COST, WHICH IS REAL AND IS NOT ABOUT SECRECY.
#
# TPM keys are protected by their own auth values, so this grants nobody the ability to read
# anybody else's sealed data. What it does grant is the ability to make failed authorisation
# attempts, and a TPM answers those by counting them and eventually refusing everyone — the same
# dictionary-attack lockout DodoSSH relies on to make a four-digit PIN safe.
#
# On a machine whose disk is unlocked by the TPM at boot (systemd-cryptenroll, Clevis), a local user
# who trips that lockout can stop the next boot from unlocking until it recovers. That is a denial
# of service against the machine's owner by somebody already sitting at the machine, and it is worth
# knowing rather than discovering. An administrator who does not accept it can mask this file:
#
# sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
#
# which is the standard override and survives upgrades. DodoSSH then behaves exactly as it did
# before this file existed: no device key is offered, and unlocking asks for the passphrase.
#
# Numbered 71 because 73-seat-late.rules is what turns the tag into an ACL, and 60-tpm-udev.rules
# is what creates the device node's ownership in the first place. Anything outside that gap either
# never runs or runs too late to matter.
ACTION=="remove", GOTO="dodossh_tpm_end"
SUBSYSTEM=="tpmrm", KERNEL=="tpmrm[0-9]*", TAG+="uaccess"
LABEL="dodossh_tpm_end"
+342
View File
@@ -0,0 +1,342 @@
# ==================================================================================================
# The Linux desktop head, as an RPM.
#
# Built by scripts/ci-linux-rpm.sh, which is what supplies every dodo_* macro below and what checks
# the result. Nothing here is invoked by hand; `rpmbuild -bb` on this file with no --define set will
# fail on the Version line, which is the intended way round.
#
# ◆ WHY THIS IS AN RPM AND NOT A VELOPACK RELEASE, WHICH IS THE FIRST QUESTION.
#
# Both desktop platforms that ship today are Velopack: the client polls the project's forge, fetches
# a newer build and swaps itself over. ADR 0013 rule 4 is what makes that safe to hold — the feed
# address is a const, so no operator can answer "is there a newer version" on the user's behalf.
#
# This package replaces nothing and installs nothing, and that is deliberate rather than unfinished.
# On Linux the package manager is already an updater the user trusts, audits and controls, so putting
# a second one inside the application — answering to this project instead — would be building the
# capability ADR 0013 rule 4 spends a page making unsettable, on the one platform where nobody had to.
#
# What the client does do is the half a package manager cannot: LinuxUpdateChannel notices that a
# newer build exists on the project's forge, fetches it, checks its digest, and hands the file to
# PackageKit — which raises polkit and asks. The user may say no, and nothing is replaced until they
# say yes. See ADR 0013 rule 11.
#
# The consequence to know: discovery is worse here than on the other two desktops, because there is
# no package repository to subscribe to and the in-app check is what stands in for one.
# ==================================================================================================
# ◆ THE THREE THINGS THAT MUST BE TURNED OFF, AND WHAT EACH ONE WOULD DO.
#
# __os_install_post is the chain of brp-* scripts rpm runs over the buildroot: strip, strip-static,
# compress, elfperms. Every one of them is wrong here. The payload is a published .NET tree whose
# managed PDBs ADR 0013 rule 6 keeps on purpose — a stack with file names and line numbers is this
# project's entire diagnostic channel — and brp-strip would take the native symbols with them for a
# saving nobody asked for. It also runs the host's binutils over glibc binaries on a build host that
# is musl, which is a class of breakage with no good failure message.
#
# debug_package would then try to build a -debuginfo subpackage out of what strip extracted, from
# third-party binaries nobody here will ever step through.
#
# _build_id_links is the one that surprises people, because turning the other two off does not turn
# it off: rpm still writes /usr/lib/.build-id/<xx>/<hash> symlinks for every ELF file in the payload.
# For a bundled tree that means dozens of links in a system directory this package has no business
# owning, and two files with the same build id — which a .NET publish does produce — make it a build
# *warning* about duplicates rather than an error, so it goes unnoticed.
%global __os_install_post %{nil}
%global debug_package %{nil}
%global _build_id_links none
# ◆ AUTO-REQUIRES STAYS ON. AUTO-PROVIDES DOES NOT. THE ASYMMETRY IS THE WHOLE POINT.
#
# rpm's elfdeps reads DT_NEEDED and the symbol-version table out of every ELF file in the buildroot,
# and the requires half of that is worth much more than a hand-written list: it produces
# `libc.so.6(GLIBC_2.38)(64bit)` rather than `glibc`, so the package refuses to install on a host
# whose glibc is too old instead of installing and dying at the first launch. A hand list cannot
# express that and would go stale the first time a dependency changed underneath it.
#
# It reads ELF directly rather than by loading anything, so it does this correctly for x86_64 glibc
# binaries on a musl build host — which is what lets this package be built on the same Alpine runner
# as everything else in ci.yml.
#
# The provides half is the opposite. Everything under /opt here is a *bundled* copy: libSkiaSharp,
# libcoreclr, libe_sqlite3 and the rest belong to this application and to nothing else. Left on,
# this package would advertise `libcoreclr.so()(64bit)` to the whole system, and some other package's
# dependency could then be satisfied by a private copy inside a directory `dnf remove dodossh` takes
# away. Excluding the tree by path is the standard shape for that and costs nothing, because nothing
# outside this package is meant to link against anything inside it.
#
# ◆ WHICH IS WHY THE REQUIRES SIDE NEEDS AN EXCLUSION TOO, AND WHY IT IS COMPUTED.
#
# The two halves are not independent. A bundled library with a DT_NEEDED on another bundled library
# produces a requirement for a soname this package carries and — after the exclusion above — no
# longer advertises, which no host on earth can satisfy. It is not hypothetical and it is not rare:
# .NET's own libmscordbi.so links libmscordaccore.so, so the very first realistic payload this was
# tried against produced a package `dnf install` refuses outright.
#
# dodo_bundled is the alternation of every lib*.so* filename in the payload, worked out by
# scripts/ci-linux-rpm.sh from the tree it was handed rather than listed here. A list in this file
# would be a list of Microsoft's and SkiaSharp's internal file names, kept in step by hand, and its
# failure mode is the uninstallable package above.
#
# liblttng-ust is excluded on its own terms and not because it is bundled — it is not. It is what
# libcoreclrtraceptprovider.so links, and that file exists so that CoreCLR can emit LTTng trace
# events if a host has the tracing stack. Nothing here asks it to. The file is kept and the
# requirement is dropped, which is the arrangement that works both ways round: where liblttng-ust is
# present the dlopen succeeds and tracing is available, and where it is absent CoreCLR carries on, as
# it does on every machine that runs a .NET application without it. Requiring it instead would put
# the package out of reach on RHEL, where lttng-ust is in EPEL rather than in the base repositories —
# an install failure on a supported distribution, in exchange for a tracing integration this
# application does not use.
#
# scripts/ci-linux-rpm.sh checks the finished package against its own file list afterwards, because
# an exclusion that silently stopped matching would put the uninstallable package back with no
# warning anywhere.
%global __provides_exclude_from ^/opt/.*$
%global __requires_exclude ^(liblttng-ust\.so|%{dodo_bundled})
Name: %{dodo_name}
Version: %{dodo_version}
Release: %{dodo_release}
Summary: SSH client with an end-to-end encrypted credential vault
License: MIT
URL: https://git.dodotech.cloud/DodoTech-Public/DodoSSH
BuildArch: x86_64
# ◆ THE DEPENDENCIES elfdeps CANNOT SEE, WHICH ARE THE ONES THE PRODUCT IS MADE OF.
#
# Everything below is reached through dlopen, by a name composed at runtime, so it appears in no
# DT_NEEDED entry anywhere in the payload. Left to the automatic scan the package would install
# cleanly on a host with none of it and fail at the first window.
#
# GTK and WebKitGTK are the terminal. Avalonia.Controls.WebView P/Invokes a bare `libwebkit2gtk` and
# a bare `libgtk` and resolves them itself, and MainWindow.OnTerminalEnvironmentRequested documents
# the backend it was measured against: WebKitGTK 2.52.5 on Fedora 44, which is the 4.1 API line — the
# assembly's one fully-spelled soname, libjavascriptcoregtk-4.1.so.0, is that line's other half.
#
# ◆ THE 4.0 LINE IS DELIBERATELY NOT CLAIMED, AND THE COST OF THAT IS KNOWN.
#
# The control's own diagnostic says "webkit2gtk 4.0+", so a dependency permitting either line is
# tempting and was written first. RHEL 9 and its rebuilds are what it would buy: they ship
# webkit2gtk3, which is the 4.0 API under the soname libwebkit2gtk-4.0.so.37 — not .so.0, which is
# what makes this a thing to look up rather than guess. Requiring only 4.1 means `dnf install` on
# AlmaLinux 9 refuses with "nothing provides libwebkit2gtk-4.1.so.0", by design.
#
# It is refused because nobody has run it. This project does not ship an Intel macOS package for the
# same reason and says so in ADR 0013 decision 10 — the engineering is small and the verification is
# the scarce thing — and an install that succeeds and then draws a blank terminal is a worse answer
# than one that refuses in a sentence naming the missing library. The day somebody runs the head on
# a 4.0 host, this becomes a rich `or` and a line in the README.
#
# libX11 is Avalonia's own backend, P/Invoked the same way. On Wayland this is satisfied by XWayland,
# which every desktop shipping this package has.
#
# libicu is the one that looks removable and is not. DodoSSH.Client.App sets InvariantGlobalization
# to false — deliberately, and the csproj says why: this process prints timestamps and host names to
# a person who expects their own locale's conventions. A .NET process configured that way and given
# no ICU does not degrade to invariant, it refuses to start. The bare package name is the Fedora and
# RHEL spelling; it is the one name here that is a distribution's rather than a soname's, because ICU
# bumps its soname on every release and pinning one would make this package expire.
Requires: libwebkit2gtk-4.1.so.0()(64bit)
Requires: libgtk-3.so.0()(64bit)
Requires: libgdk-3.so.0()(64bit)
Requires: libgio-2.0.so.0()(64bit)
Requires: libglib-2.0.so.0()(64bit)
Requires: libgobject-2.0.so.0()(64bit)
Requires: libX11.so.6()(64bit)
Requires: libicu
# The size directories under /usr/share/icons/hicolor belong to hicolor-icon-theme and not to this
# package, which is the right way round — every application drops icons into them. Depending on the
# owner is how those directories come to exist at all on a minimal host, and how they get removed
# again when the last thing using them goes.
Requires: hicolor-icon-theme
# ◆ RECOMMENDED RATHER THAN REQUIRED, WHICH IS A JUDGEMENT ABOUT WHAT THE PACKAGE IS FOR.
#
# PackageKit is what LinuxUpdateChannel hands a downloaded update to, so without it the update path
# stops at the last step. It is not required, for two reasons that both point the same way: dnf
# installs weak dependencies by default, so the ordinary Fedora install gets it anyway; and a machine
# deliberately built without it — a minimal or an immutable one — should still be able to install an
# SSH client. The failure is not silent, which is what makes this safe: the channel names the missing
# service and the file it fetched, so the answer is one command the user was going to run anyway.
#
# Nothing is required for the TPM. LinuxDeviceKeyStore talks to /dev/tpmrm0 through a managed stack,
# so there is no library to depend on, and a machine with no TPM — or one whose device node this user
# may not open, which is the ordinary Fedora case — simply keeps asking for the passphrase.
Recommends: PackageKit
%description
DodoSSH is an SSH and SFTP client for teams whose credentials are shared and whose infrastructure is
not. Hosts, keys and passphrases live in a vault that is encrypted on this machine and stays
encrypted on the server, so the deployment holding a team's secrets can never read them.
This package carries its own .NET runtime. It checks the project's own forge for newer builds and can
download one, but it installs nothing itself: an update is handed to PackageKit, which asks before it
replaces anything. The address it checks is fixed and is never a DodoSSH server.
%install
rm -rf %{buildroot}
# ◆ /opt, AND NOT /usr/lib64/dodossh, WHICH IS THE MORE CONVENTIONAL PLACE.
#
# The Filesystem Hierarchy Standard reserves /opt for exactly this — an add-on package that arrives
# as one self-contained tree — and that is what a published .NET application is: a runtime, thirty
# managed assemblies and half a dozen native libraries that only make sense together and are only
# ever loaded by each other. Scattering them into the system library directory would mix a bundled
# libSkiaSharp in with the system's shared objects, which is the arrangement the provides exclusion
# above exists to keep this package out of.
#
# The directory name carries the channel, so a release and a nightly are two trees rather than one
# that overwrites the other. That is the same separation ADR 0013 rule 9 builds on Windows out of
# pack ids, arrived at here for free: two RPMs with different names owning different paths cannot
# replace one another, and dnf will say so rather than doing it. The profile directory is separated
# by the build itself — DodoChannel=nightly is what ClientPaths reads to suffix it — so the two
# installs share no state at all.
mkdir -p %{buildroot}/opt/%{dodo_dir}
cp -a %{dodo_payload}/. %{buildroot}/opt/%{dodo_dir}/
# ◆ AND THE MODES ARE SET HERE RATHER THAN INHERITED, WHICH IS NOT TIDINESS.
#
# `cp -a` preserves what the publish directory happened to have, and what it happened to have is a
# property of the filesystem it was written on rather than of anything decided here. rpmlint caught
# the consequence on the first tree this was tried against: every file in the package was mode 777,
# because the tree had come off a mount that reports everything that way.
#
# For this application that is not a lint warning, it is a hole. A world-writable /opt/DodoSSH lets
# any local account replace the SSH client belonging to every other account on the machine — the
# binary that holds a team's infrastructure credentials in plaintext while it runs, which is the
# thing ADR 0001 spends its whole trust model on. Malware running as nobody-in-particular would not
# need to attack the crypto; it would edit the client.
#
# Stated absolutely and in this order, so the answer does not depend on the publish at all:
# directories and the things that are executed or loaded get 0755, and everything else — assemblies,
# PDBs, the runtime config, the terminal's HTML and JavaScript — gets 0644. Owned by root, which is
# rpm's default for a package that names no %attr user, and is what makes the modes mean anything.
find %{buildroot}/opt/%{dodo_dir} -type d -exec chmod 0755 {} +
find %{buildroot}/opt/%{dodo_dir} -type f -exec chmod 0644 {} +
find %{buildroot}/opt/%{dodo_dir} -type f -name '*.so*' -exec chmod 0755 {} +
# The two files a .NET publish means to be executed. createdump is the runtime's crash-dump helper
# and is only present on some publishes, so it is conditional; the apphost never is, and the check
# above this section has already refused a publish without one.
chmod 0755 %{buildroot}/opt/%{dodo_dir}/DodoSSH
[ -f %{buildroot}/opt/%{dodo_dir}/createdump ] && chmod 0755 %{buildroot}/opt/%{dodo_dir}/createdump
true
# The apphost is the thing to launch and every other file in the tree is loaded relative to it, so
# what goes on PATH is a symlink rather than a copy or a wrapper script. .NET resolves its own
# directory through /proc/self/exe, which follows the link, so a symlinked apphost finds its runtime
# with no help — a wrapper exporting DOTNET_ROOT or cd-ing first would be solving a problem that
# does not exist here, and would be one more file to keep in step with the layout.
#
# Relative — ../../opt from /usr/bin — rather than the absolute /opt path it resolves to. Both work
# on a running system and only one works inside a chroot or an image being assembled somewhere other
# than /, which is where absolute symlinks out of a package are checked and warned about.
mkdir -p %{buildroot}%{_bindir}
ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
# ◆ THE DESKTOP ENTRY IS WRITTEN HERE RATHER THAN SHIPPED AS A SOURCE FILE.
#
# Three of its seven meaningful lines differ between the release and the nightly, and the two files
# that would result are the kind of pair that drifts: somebody adds a MIME type to one of them and
# nobody notices for a release. Composed from the same macros the rest of the layout uses, they
# cannot.
#
# StartupWMClass is the line that is easy to leave out and visibly wrong without: Avalonia sets
# WM_CLASS from the assembly name, which is DodoSSH for both channels, so without this a launched
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
# name deliberately and not the package's binary name — the window does not know which package
# started it.
mkdir -p %{buildroot}%{_datadir}/applications
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
[Desktop Entry]
Type=Application
Version=1.0
Name=%{dodo_title}
GenericName=SSH Client
Comment=SSH and SFTP with an end-to-end encrypted credential vault
Exec=%{dodo_bin}
Icon=%{dodo_bin}
Terminal=false
StartupNotify=true
StartupWMClass=DodoSSH
Categories=Network;RemoteAccess;System;
Keywords=SSH;SFTP;Terminal;Remote;Shell;
DESKTOP
# One file per size into the hicolor theme, named for the package so the two channels show different
# entries rather than fighting over one path. The sizes are the ones the icon script draws and its
# comment argues for; adding one there means adding it here.
for size in 48 64 128 256; do
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps
install -m 0644 %{dodo_icons}/dodossh-${size}.png \
%{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps/%{dodo_bin}.png
done
# ◆ THE UDEV RULE, WHICH IS THE ONE FILE THIS PACKAGE PUTS OUTSIDE ITS OWN TREE.
#
# It tags /dev/tpmrm0 with uaccess so the person at the machine can use the TPM without being put in
# the tss group by an administrator. The rule itself carries the whole argument, including what it
# costs and the one-line way to mask it.
#
# Installed for both channels, and that is not a mistake to be tidied up. The two packages own
# different paths for the same reason ADR 0013 rule 9 gives, but this file is about the machine's
# TPM rather than about either build — a nightly and a release on one machine want exactly the same
# access, and giving each its own copy would mean removing one package silently revoked the other's.
# rpm refuses to let two packages own one path with different content, which is what makes this
# safe: the file is byte-identical from both, so it is shared rather than duplicated.
#
# /usr/lib/udev/rules.d hardcoded rather than %{_udevrulesdir}, which comes from systemd-rpm-macros
# and is not present on the Alpine host CI builds this on. Hardcoding a path that has been stable
# for a decade beats a macro that expands to nothing and silently installs into the buildroot's root.
mkdir -p %{buildroot}/usr/lib/udev/rules.d
install -m 0644 %{dodo_udev} %{buildroot}/usr/lib/udev/rules.d/71-dodossh-tpm.rules
# Installed rather than listed straight out of the source tree. `%license` is a %doc-style marker and
# resolves its argument against the build directory, which this spec does not have — there is no
# %prep and no %build, because the payload arrives already published. Putting the file where it
# belongs first and marking the installed path second is the form that works with neither.
mkdir -p %{buildroot}%{_licensedir}/%{name}
install -m 0644 %{dodo_licence} %{buildroot}%{_licensedir}/%{name}/LICENSE
# Modern rpm on Fedora fires file triggers that do both of these, so on the distribution this was
# measured against they are redundant. They are here for the ones that do not, and every line ends in
# `|| :` so that a host without the tool installed gets a package that installs rather than a
# scriptlet failure at the end of a 130 MB transaction.
%post
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
# Reloaded and replayed, because a rule that only takes effect at the next boot is a feature that
# does not work on the day it is installed — and "reboot and try again" is the worst possible answer
# to "why is the PIN option missing". The trigger is scoped to the tpmrm subsystem so this re-runs
# rules for one device rather than for every device on the machine.
/usr/bin/udevadm control --reload &>/dev/null || :
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
%postun
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
# On the way out too, so removing the package takes the ACL with it rather than leaving the last
# session's user holding access to the TPM until the next reboot.
/usr/bin/udevadm control --reload &>/dev/null || :
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
%files
# The whole tree, owned by this package, which is what makes `dnf remove` take all of it. The user's
# vault cache is not in here and is not touched by an uninstall — ClientPaths puts it under
# $XDG_DATA_HOME, which is the Linux spelling of the same decision ADR 0013's consequences record for
# Windows: the install moves, the profile does not, and an uninstall is not a sign-out.
/opt/%{dodo_dir}
%{_bindir}/%{dodo_bin}
%{_datadir}/applications/%{dodo_bin}.desktop
%{_datadir}/icons/hicolor/*/apps/%{dodo_bin}.png
/usr/lib/udev/rules.d/71-dodossh-tpm.rules
%license %{_licensedir}/%{name}/LICENSE
%changelog
# Deliberately empty. The changelog of record is the git history and the release notes on the forge,
# and a %changelog generated per build from a version number carries no information that `rpm -q`
# does not already answer — while a hand-written one is a third place a release has to remember to
# edit. rpmlint says W: no-changelog and that warning is accepted.
+2 -1
View File
@@ -26,7 +26,8 @@
"post.logout.redirect.uris": "http://127.0.0.1/*"
},
"redirectUris": [
"http://127.0.0.1/callback"
"http://127.0.0.1/callback",
"dev.dodotech.dodossh:/callback"
],
"webOrigins": [],
"protocolMappers": [
+80 -3
View File
@@ -107,6 +107,81 @@ almost nil. Two things to check when it is taken up: `EnrollmentValidation` pins
`DevicePublicKey` to `CryptoSpec.PublicKeySize` (32 bytes; a P-256 public key is 33 or 65), and the
envelope's minimum-length rule.
### 4. Linux: sealed into the TPM behind a PIN the TPM checks
Added after the Linux head was packaged ([ADR 0013](0013-desktop-distribution-and-updates.md) decision 11).
The third desktop platform reached this file the way the first two did — by having to answer the amendment
above — and it is the only one where the answer is not a clean yes.
**The mechanism.** The 32-byte device key is sealed into a TPM 2.0 keyedhash object whose `userAuth` is
derived from a PIN. Unsealing is a command the TPM refuses without it. The blob on disk is encrypted to the
machine's storage hierarchy, so it is useless on any other machine, and nothing in this application ever
compares the PIN. The parent is an ECC P-256 primary rederived from a fixed template on every use rather
than made persistent — a persistent handle would occupy an NV slot forever, need cleaning up on withdrawal,
and collide with whatever else on the machine chose the same index. ECC rather than RSA because creating an
RSA primary on a discrete TPM takes seconds and this runs on the unlock path.
**The sealed object deliberately does not carry `NoDA`, and that single attribute is what makes a
four-digit PIN defensible.** Every wrong answer advances the TPM's dictionary-attack counter and the chip
stops answering entirely once it trips — measured against a software TPM at four wrong tries, after which
the *correct* PIN gets nothing either. That is the property a short secret has here and cannot have in a
file, and it is asserted by a test rather than left to a reading of the template, because removing the
attribute would leave every other assertion in that file passing.
◆ **This is weaker than Windows Hello and Touch ID, and the difference is invisible to the user.** Both of
those raise their prompt from inside the platform — a secure desktop, a system sheet — so the process asking
never sees the secret. Linux offers nothing to borrow, so the PIN is typed into a window this application
draws, and an attacker already running as the user could draw a convincing copy of it. Two things bound
that, and neither erases it: the same attacker could counterfeit the passphrase screen, which is a much
larger prize, so the feature creates no capability that did not exist; and what they would obtain is a PIN
that is worthless without this machine's TPM, where the passphrase is worth every machine. **The parity is
not claimed anywhere in the interface**, which is why `DesktopDeviceKeyStores` documents the ordering and
why the prompt is deliberately plain rather than dressed to look like a system dialog — teaching people
that a convincing DodoSSH PIN box is a system dialog is the one thing this design must not do.
**Reaching the TPM at all is the other half, and the package now arranges it.** On Fedora `/dev/tpmrm0` is
`root:tss` mode 0660 and desktop users are not in `tss`, so without help the ordinary case is a working TPM
this process may not open — which reports unavailable and keeps asking for the passphrase, exactly as a
machine with no TPM does.
◆ **This originally said shipping a udev rule to widen that was refused, on the grounds that it is a
system-wide decision an application package should not make. That refusal was overturned deliberately, and
the reasoning it was replaced with is narrower rather than louder.** The refusal treated "widen access" as
one thing. It is two, and they are not close:
- *Adding a person to `tss`* is what the refusal was really about, and it remains the wrong answer. It is
permanent, it applies whether or not they are at the machine, it applies over SSH, and it needs an
administrator and a fresh login before the feature appears at all.
- *Tagging `/dev/tpmrm0` with `uaccess`* is the mechanism systemd already uses for `/dev/dri`, webcams,
scanners and FIDO keys: logind grants a POSIX ACL to the user of the **active local session** and removes
it when that session ends. Not over SSH, not on an inactive VT, not to daemons. `build/linux/71-dodossh-tpm.rules`
is that one line, and it changes neither the mode nor the group, so anything that worked before still does.
**`tpmrm` and never `tpm`, which is what makes it defensible at all.** `/dev/tpm0` is the raw device and is
exclusive; a process holding it can leave the chip in a state that breaks every other user. `/dev/tpmrm0` is
the kernel's resource manager, which virtualises handles and isolates callers — it exists *because* the TPM
is meant to be usable by more than one unprivileged program. Granting a seat's user the manager is the use
it was designed for.
**The cost is a denial of service and not a disclosure, and it is stated because it is easy to miss.** TPM
objects are protected by their own auth values, so this lets nobody read anybody else's sealed data. What it
does allow is *failed* authorisation attempts — and the chip answers those by counting them and eventually
refusing everyone, which is the same lockout decision 4 relies on. On a machine whose disk is unlocked by
the TPM at boot (`systemd-cryptenroll`, Clevis), somebody sitting at it can trip that lockout and stop the
next boot from unlocking until it recovers. An administrator who does not accept that masks the file with
`ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules`, which is the standard override, survives upgrades,
and puts the behaviour back to the passphrase-only path. The rule says so in its own comments, because the
person who needs to know is reading the file rather than this document.
**What was rejected, and why each would have failed the amendment above.** The Secret Service API —
gnome-keyring or KWallet — is out of process and OS-managed and has *no per-use consent*: once the session
keyring is unlocked anything running as the user reads the key silently, which is DPAPI's shape exactly and
what "Why not DPAPI alone" already refuses. `fprintd` is an authentication oracle rather than a key store:
it answers a question and holds nothing, so skipping the call skips the gate — the amendment's mistake with
a fingerprint reader attached. A polkit-gated root helper would genuinely work and was declined on surface
area: it puts a privileged component in a package that installs nothing setuid, to reach a property the TPM
already provides.
## Consequences
### The cache key had to move, and the spec changed
@@ -138,9 +213,11 @@ would have become false under DPAPI alone. A gesture is still something the atta
### Operational
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
is therefore required, not a nicety.
- **A TPM is not always there, and on Linux it is often there and out of reach.** A machine without one gets
a store that reports itself unavailable, so unlock keeps asking for the passphrase and neither affordance
appears in the interface. The passphrase path is therefore required, not a nicety — and on Linux it is the
default rather than the exception, because the device node is not readable by an ordinary desktop user
until somebody says it should be. See decision 4.
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
@@ -316,6 +316,106 @@ preferences screen, which is the screen somebody reads when asked which nightly
across the floor holds: heights rise within a floored version, and the first real tag moves past all of
them. The release script gets no floor and must not have one; there, `0.0.0` should be refused.
### 11. Linux is a third desktop platform, and it is a package rather than an updater
The Linux head existed and shipped as nothing: a checkout somebody built. `VelopackUpdateChannel` already
named the two platforms it serves rather than negating the one it does not, on the reasoning that "a future
AppImage should be an addition here rather than a thing that silently already half-happened". This is that
addition, and it is not an AppImage.
**The package is an RPM and nothing inside it replaces anything.** That is the decision, and everything
else follows from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been
repeated a third time — one more channel, one more index, one more client swapping its own files over. It
should not be, because this platform already has the mechanism that whole apparatus is a substitute for.
`dnf` is an updater the user already trusts, already audits and already controls, and it updates the
runtime's own dependencies besides. Adding a second one that answers to this project instead would be
building the capability decision 4 spends a page keeping unsettable, on the one platform where nobody had
to.
◆ **The first version of this rule said "there is no updater in it at all", and that went too far.** It
conflated two things the other two platforms happen to do together: *noticing* that a newer build exists,
and *installing* it. The second is what the argument above is about, and the first is not — a client that
checks a fixed address and tells you what it found has taken no capability from anybody. Written as a flat
refusal, the rule bought its safety by paying the cost in the paragraph below, and the cost was avoidable.
**So the split is: this client checks and fetches, and PackageKit installs.** `LinuxUpdateChannel` reads
the same rolling release the Windows nightly feed lives on, compares versions the way `rpm` compares them,
downloads the package, verifies its digest, and hands the file to PackageKit — which raises polkit and asks
the user, who may say no. `ApplyingEndsTheProcess` is false and nothing is torn down before the answer, which
is the phone's shape rather than the desktop's, and is already what `MainWindowViewModel` does with that
flag. Four things follow that are worth stating because each is a property the Velopack channels do not have
to think about:
- **The comparison is `rpmvercmp` and not SemVer.** Decision 7 made one version of record for the whole
repository, and this is the one place it is not the string that decides: `rpm` will accept or refuse the
install, so the client must compare the way `rpm` does or it can offer an update PackageKit then declines.
It is the same argument [ADR 0014](0014-android-updates.md) makes for comparing `versionCode`. The
manifest therefore carries both — the RPM version to compare and the SemVer to show a person — and the
installed side is read out of the rpm database rather than derived from the assembly, so the translation
`scripts/ci-linux-rpm.sh` performs exists in exactly one place.
- **The digest is checked, and it is not a signature.** Velopack verifies what it downloads and Android
refuses a package whose signature does not match the installed one. An RPM fetched over HTTPS and handed
to a package manager has neither, so the manifest names a SHA-256 and the client refuses anything else.
That catches a truncated download and a caching proxy and says nothing whatever about provenance, because
the same job writes the package and the manifest. Provenance here is decision 4's constant address.
- **`ONLY_TRUSTED` is cleared, deliberately.** The package is unsigned, so requiring PackageKit to trust it
would fail every update at the last step. Clearing the flag routes the request to polkit's
`package-install-untrusted` action, which asks for an administrator rather than proceeding quietly — the
user is told what they are agreeing to, which is the honest form of an unsigned package.
- **Nothing restarts.** Replacing files under a running process is ordinary on Linux and the old inodes stay
mapped, so the build keeps running until somebody closes it. That is the same courtesy decision 1's
consequences argue for on Windows, arrived at for free.
The honest cost that remains, recorded because it is the part that will be argued with: **discovery is still
worse here than on either other desktop, for anybody who declines the in-app check.** There is no package
repository to subscribe to, so `dnf upgrade` on its own will never bring a newer DodoSSH; the in-app check is
what stands in for one, and a user who turns it off on PREFERENCES → UPDATES has no other signal. A signed
repository is the fix, it is not built, and decision 11's last paragraph says what it would cost.
**CI publishes it, and only to the nightly channel.** Rule 3 is untouched and applies with its full force:
the token that writes a release is the capability to ship somebody a build, and it stays off runners. What
changes is nothing, because rule 9 already established a channel CI may publish to and this is a second
artefact on it. The release-shaped package is built on every `main` build and on every tag and uploaded
nowhere, exactly as the Windows and macOS packages are, which proves the `linux-x64` restore graph and the
packaging without putting an installer anybody has run in a place that invites passing it on.
Note what CI's check is and is not. `vpk` verifies that the main executable actually calls
`VelopackApp.Build().Run()` — a real assertion about the application, made for free by the packager. There
is no equivalent here, so `scripts/ci-linux-rpm.sh` makes its own: it asks the finished package what it
requires, what it provides, what files it holds and what modes they carry, and fails on each way a package
can build cleanly and then be uninstallable or unsafe. Two of those checks exist because the first realistic
payload tripped them — .NET's `libmscordbi.so` links `libmscordaccore.so`, which with bundled provides
excluded produces a dependency no host can satisfy; and `cp -a` off the wrong filesystem produced a
world-writable `/opt/DodoSSH`, which is a local privilege escalation against an application holding
plaintext credentials.
**The separations of rule 9 survive translation, and cost less here.** Two RPMs with different names owning
different paths cannot replace one another and `dnf` says so, where on Windows the same property had to be
built out of pack ids. The profile split is unchanged and still belongs to the build: `DodoChannel=nightly`
is what `ClientPaths` reads, and the packaging must not answer that question a second time.
**The device key store is settled separately and is not nothing.** The first version of this rule said this
platform had none and that the passphrase was asked for at every unlock. It has one:
[ADR 0007](0007-device-key-protection.md) decision 4 adds a TPM-sealed key released against a PIN the TPM
itself checks and counts failures against. It is weaker than Hello and Touch ID in a way that ADR names
precisely. Reaching the chip needs no group membership, because this package ships the udev rule that gives
the seated user an ACL on `/dev/tpmrm0` — a decision that ADR overturned from its own first answer, and
whose cost it states.
**What this platform does not have is a signature on the package**, which is not decision 8's "unsigned for
now": nothing on this platform
refuses or warns about an unsigned RPM, so there is no dialog to buy a certificate to remove. What stands in
for one is the same thing that stands in for it on the forge — TLS, and a project that publishes from one
address. An RPM repository would change that, because repository metadata is signed and a repository is a
thing users add once and then trust; that is the point at which a key becomes necessary rather than optional,
and it is the reason building one is a decision and not a chore.
**x86_64 only, for decision 10's reason and not a different one.** aarch64 is one argument to the publish
and one to the script. What is missing is somebody to run Phase 19 on such a machine, and this project does
not ship desktop builds nobody has run. The same sentence rules out claiming RHEL 9, which ships WebKitGTK's
4.0 API line under a soname the package deliberately does not accept: `dnf` refusing with *nothing provides
libwebkit2gtk-4.1.so.0* is a better answer than an install that draws a blank terminal.
## Consequences
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
@@ -369,6 +469,10 @@ ADR 0011 changes.
genuinely the nicest onboarding available and will be proposed again.
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
inherit it.
- **A Velopack channel for Linux.** The mechanism exists — Velopack builds an AppImage — and repeating
decisions 1 through 9 a third time would have been the consistent-looking move. Declined in decision 11
because this is the one desktop platform that already has an updater the user controls, and a second one
answering to this project is the capability decision 4 exists to make unsettable.
- **Azure Trusted Signing**, for now, on custody grounds rather than on mechanics. See decision 8.
- **A hand-edited version property.** Considered seriously against MinVer, and it loses on the thing that
matters here: the tag is already the version of record for the container image, and two places to write
+246
View File
@@ -0,0 +1,246 @@
# ADR 0015 — The Android terminal's input path leaves the WebView
- Status: proposed — **Stage 1 built, unverified on a device**
- Date: 2026-08-25
- Builds on: [`docs/android-port.md`](../android-port.md) §9, [ADR 0011](0011-android-distribution.md)
- Does not settle: whether the renderer stays xterm.js. That is [Stage 3](#stage-3--the-renderer-deliberately-unsettled), and it is deliberately left open.
## Context
The phone got the desktop's terminal, and the reasons were good ones. `TerminalDataPlane` already
served the page and a binary protocol over a loopback WebSocket, the xterm.js bundles were embedded
resources and platform-neutral, and `Avalonia.Controls.WebView` shipped a `net10.0-android36.0`
target. `docs/android-port.md` called it *"better news than expected"*, and for rendering it was.
**For typing it was not, and typing is the complaint.** Not scrollback, not colour, not throughput —
the act of putting characters into a shell. That narrows the problem usefully, because the input path
and the render path are separable, and only one of them is wrong.
### Three things sit between a finger and a byte, and the middle one is the fault
**1. Avalonia and the WebView fight over native focus.** `AvaloniaView.DispatchTouchEvent` ends every
handled touch — DOWN and UP alike — with a `RequestFocus()` for Avalonia's own view. The WebView's
input connection dies with its focus, so the keyboard swaps layouts and the inset churn can park it
over the accessory row that was tapped. `Platform/TerminalFocus.cs` exists to hand focus back, posted
to the next main-looper message so the platform has taken its turn first. It works. It is also a
counter-move in a fight that only exists because the thing holding the input connection is not the
thing the framework thinks is focused.
**2. The keyboard is a prose keyboard, and no DOM attribute makes it stop.** This is the one that was
worth checking rather than assuming, and the check inverted the obvious conclusion.
The vendored xterm.js *already* sets every hint the web platform has on its helper textarea —
`autocapitalize="off"`, `autocorrect="off"`, `spellcheck="false"`. They are set, and Gboard shows the
suggestion strip and predictive text anyway. So this is not a missing attribute; it is a ceiling.
Termux's `TerminalView.onCreateInputConnection` says what the ceiling is. It sets:
```
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
```
**`VISIBLE_PASSWORD` is the load-bearing half.** `NO_SUGGESTIONS` alone is advisory and most keyboards
ignore it — a point made in Avalonia's own Android keyboard PR discussion, not only in Termux's. What
actually turns off autocorrect, the suggestion strip and the predictive composing layer is the
*variation*: a field the keyboard believes is a visible password gets no dictionary. That is an
`EditorInfo.inputType` on a native `InputConnection`, and a page inside a WebView has no way to reach
it — Chromium computes the `EditorInfo` from the focused DOM node, and no combination of attributes on
a `<textarea>` produces the visible-password variation.
**3. The accessory row is a workaround for a keyboard, not a keyboard.** Esc, Tab, Ctrl and the arrows
are right and stay. But every tap on one costs the focus round-trip in (1), and none of them can be
held or repeated.
### The finding that decides this ADR
Avalonia already emits Termux's exact `inputType`. Decompiled from the pinned
`Avalonia.Android` 12.1.1, `AndroidInputMethod<TView>.OnCreateInputConnection`:
```csharp
if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
{
outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
}
```
`0x80080` is `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS`. The guard reads:
`ContentType` left at `Normal`, `Multiline` false, `AutoCapitalization` false, and
`TextInputOptions.ShowSuggestions` explicitly `false` — a `bool?` whose default is `null`, so it must
be set, not merely left alone. The same method also ORs `IME_FLAG_NO_EXTRACT_UI |
IME_FLAG_NO_FULLSCREEN` into `imeOptions`, which is the other thing Termux sets and for the same
reason: a terminal must never be replaced by a full-screen extract editor in landscape.
So the keyboard behaviour that makes Termux feel like a terminal is **already available on the
version this repository pins**, to any Avalonia control that asks for it. It is not available to the
WebView, and never will be. That asymmetry is the whole decision.
## Decision
### Avalonia owns the keystrokes. The WebView renders and nothing else.
A focusable Avalonia control — `TerminalInputSurface` — becomes the terminal's input target. It draws
nothing. It sits in the same `Panel` as the renderer, takes focus when the terminal surface is shown,
and keeps it for as long as that surface is up.
- `TextInputOptions.ShowSuggestions = false`, with `ContentType`, `Multiline` and `AutoCapitalization`
left at the defaults the guard above requires. This is the entire fix for the suggestion strip.
- `TextInputOptions.ReturnKeyType = Return`, which is the fix for Enter — see the correction below.
- It implements `TextInputMethodClient` with an empty surrounding text and a zero-length selection.
A terminal has no document for an IME to edit; committed text is forwarded and nothing is retained.
- Committed text and key events are encoded to bytes and written to the session through the same path
`TerminalScreen`'s accessory keys already use. No new transport, no change to `TerminalDataPlane`.
- The native WebView is made non-focusable (`Focusable = false`, `FocusableInTouchMode = false`) once,
at attach.
**`Platform/TerminalFocus.cs` is deleted by this, not amended.** The fight it referees ends when only
one side is a focus candidate. Its remarks should move into the new control as the record of why the
WebView is deliberately unfocusable, because that reasoning outlives the workaround.
`Platform/SoftKeyboard.cs` stays. Dismissing a keyboard on the way to a sheet is still a platform
call, and Avalonia still offers no way to close one.
### Not: patching xterm.js's textarea
Already tried by xterm.js itself, and already failing — see Context (2). The attributes are set. The
platform ignores them. There is no version of this that reaches `VISIBLE_PASSWORD` from inside a
WebView, so the effort buys nothing.
### Not: a native Android `View`, Termux-shaped, today
Termux's `TerminalView` + `TerminalRenderer` drawing to a `Canvas` is the right architecture for a
phone-only terminal, and DodoSSH is not a phone-only terminal. Writing one now means two terminal
implementations — this one and the desktop's — with two escape-sequence behaviours to keep honest
against each other, for a defect that is entirely in the input path. That is a large bill for the
wrong problem.
It also does not become unavailable later. Stage 3 keeps it on the table.
### Not: replacing xterm.js now
Rendering is not the complaint. xterm.js with the WebGL addon draws a correct, fast grid, and
`terminal.js` already handles renderer death, reattach and refit — hard-won behaviour recorded in
`docs/android-port.md` that a rewrite would have to re-earn. Replacing a working renderer to fix a
keyboard is the wrong trade, and doing it in the same change as the input move would make neither
half attributable when the result is judged on a device.
## Staging
### Stage 1 — the input surface · **built**
The decision above, and nothing else. This is the change that answers the complaint, and it should be
judged on a device on its own before anything else is built on it.
What landed:
| | |
| --- | --- |
| `Views/TerminalInputSurface.cs` | The 1×1 focusable control, `ShowSuggestions = false`, the IME client that retains nothing, and the key→escape-sequence table |
| `Views/TerminalInputEventArgs.cs` | One keystroke's bytes, on the way to `TerminalScreen.SendAsync` |
| `Platform/RendererFocus.cs` | Makes the `WebView` non-focusable and reports taps on it |
| `Platform/TerminalFocus.cs` | **Deleted.** The fight it refereed has one contestant now |
| `Views/TerminalScreen.axaml{,.cs}` | Hosts the surface, raises the keyboard on arrival, drops the two posted focus counter-moves from the accessory row |
**One thing gained that was not asked for, and it was a real defect:** the Ctrl latch now reaches the
software keyboard. It never did before — everything typed went straight into the page and never passed
`SendAsync`, which is where the latch lives — so `ctrl` then `c` was inert unless the second key also came
from the accessory row. Check 11.10d covers it.
**Verify before building anything on this:** checks 11.10a–11.10e in `docs/manual-checks.md`, and 11.10b
first of all, because it is the falsification test at the end of this document.
> **⚠️ Corrected on the first device run: every Enter closed the keyboard.** The premise held — the
> suggestion strip, autocorrect and predictive text were all gone — but the field had been left with
> Avalonia's default `ReturnKeyType`, and the same `OnCreateInputConnection` maps that, on a field that is
> not `Multiline`, to `IME_ACTION_DONE`:
>
> ```csharp
> _ => options.Multiline ? 1073741824 : 6, // 6 is IME_ACTION_DONE
> ```
>
> Done means done: the keyboard performs the action and dismisses itself. On a text box that is right, and
> on a terminal it fires on every command. `TextInputReturnKeyType.Return` — value 1, so
> `returnKeyType - 1 => 0 => 1073741824` — is `IME_ACTION_NONE`, which leaves Enter a plain newline with no
> action for the IME to perform and no reason for it to hide.
>
> **The trap worth recording, because it is the same trap 11.10b already warned about from the other side:**
> `Multiline = true` also reaches `IME_ACTION_NONE`, through the default arm, and is the wrong way there. It
> ORs `TYPE_TEXT_FLAG_MULTI_LINE` into `InputType`, which moves it off `1` and silently fails the
> `ShowSuggestions` guard. Taking the obvious route to a working Enter would have handed the suggestion
> strip back and looked like the whole ADR had failed. `ReturnKeyType` touches `imeOptions` only.
>
> Enter's *byte* was never in doubt and is not what changed — `IME_ACTION_DONE` was already synthesising
> `KEYCODE_ENTER` through `AvaloniaInputConnection.PerformEditorAction`, so commands ran; the keyboard just
> left afterwards. `OnTextInput` now also normalises line endings to CR, for the case where a keyboard with
> no editor action commits `"\n"` as ordinary text instead of sending a key event, and so that a pasted
> `"\r\n"` does not run its line twice.
### Stage 2 — the keys around it
Only once Stage 1 is proven, because each of these is cheap *given* Avalonia owning input and awkward
without it:
- **Hold-to-repeat** on the arrows and Backspace. Currently impossible without a focus round-trip per
tap.
- **Ctrl held rather than latched**, with latching kept as the fallback. `TerminalScreen`'s remark is
right that latching is what phone SSH clients do; held becomes possible when one control sees both
the modifier press and the following keystroke.
- **Pinch and volume keys for font size**, replacing the pinned `A−`/`A+` buttons. Both are what
JuiceSSH does and what its users name first; the buttons stay as the discoverable path.
Volume-key capture is an `onKeyDown` on the activity and is deliberately opt-in — a phone that cannot
change its volume with a shell open is a worse phone.
### Stage 3 — the renderer, deliberately unsettled
If, after Stages 1 and 2, the terminal still reads as a web page — touch selection, scrollback
survival across renderer death, scroll feel — then the renderer is the next question, and the
candidates are known:
| Option | What it is | Cost |
| --- | --- | --- |
| [XTerm.NET](https://github.com/tomlm/XTerm.NET) | MIT, headless, UI-agnostic; VT100/ANSI, dual buffers, 256/true colour, wide chars | Evaluate emulation fidelity against xterm.js |
| [VtNetCore](https://github.com/darrenstarr/VtNetCore) | MIT, .NET Standard 2.0, older but proven | Maintenance status |
| Port xterm.js's core | Exact behavioural parity with the desktop | Largest, and a permanent fork |
Rendered by a custom Avalonia `Control` via `DrawGlyphRun`, which is Termux's architecture expressed
in Avalonia and would work on the desktop too — eventually letting the WebView2 dependency go, and
with it the `targetSdk` 36 floor `Avalonia.Controls.WebView` currently forces.
**Stage 1 is not wasted if Stage 3 happens.** The input surface is exactly the layer such a control
would need, unchanged. That is the reason for splitting them.
## Consequences
**What gets better, and it is one thing done properly.** No suggestion strip, no autocorrect, no
predictive composing layer, no full-screen extract editor, and no focus flap on an accessory key.
That is the difference between typing at a shell and fighting a phone.
**CJK and other composing input is degraded, and this is a real cost.** `VISIBLE_PASSWORD` suppresses
composition, which is precisely what makes it work for a terminal and precisely what a Chinese,
Japanese or Korean user needs. Termux has the same limitation and lives with it. If it ever needs
answering, the answer is a per-session toggle that drops `ShowSuggestions = false` and accepts the
prose keyboard back — not a middle setting, because there isn't one.
**Text selection and copy are unchanged**, still xterm.js's own DOM handling, still without touch
selection handles — [xterm.js #5377](https://github.com/xtermjs/xterm.js/issues/5377) is open and
unassigned and this ADR does not fix it. Stage 3 territory.
**Scrollback still dies with the renderer process.** Unchanged, still honest about it via the replay
banner, still Stage 3 territory.
**Nothing here is verifiable headless**, for the reason `docs/manual-checks.md` §11 already gives.
Stage 1 needs new manual checks — at minimum: the suggestion strip is absent with a shell open; an
accessory key does not disturb the keyboard (§11.10a, which should now pass trivially rather than by
posted counter-move); landscape does not produce a full-screen extract editor; and a hardware
keyboard still reaches the shell.
**The hardware-keyboard path moves.** It currently arrives through the WebView; after this it arrives
through Avalonia. Behaviour should be identical and probably will not be on the first try.
## What would falsify this
If, on a device, `ShowSuggestions = false` does not suppress the suggestion strip — because the guard
was not satisfied, or the keyboard ignores the variation too — then Stage 1's premise is wrong and
the honest next step is Stage 3 with a native `InputConnection`, not more Avalonia. **Check this
first, on the smallest possible spike, before building `TerminalInputSurface` properly.** It is one
attached property on a focusable control and a look at the keyboard.
+12
View File
@@ -269,6 +269,12 @@ accessory key row for this. That is UI work, not porting.
> reconnected; earlier output stayed on the host"* — is that honesty put where the person looking at the
> terminal will actually read it, not buried in a log.
> **⚠️ Under review, on the input half only.** The renderer's story above stands; what has not held up in
> use is *typing*. A software keyboard over a WebView cannot be told to stop autocorrecting — the DOM
> attributes xterm.js already sets are advisory and Android ignores them — and the accessory row pays a
> focus round-trip per tap. [ADR 0015](adr/0015-android-terminal-input.md) proposes moving the input path to
> Avalonia and leaving the renderer exactly as this section describes it.
---
## Decisions taken
@@ -749,6 +755,12 @@ Recorded so they are choices rather than accidents. Any of them is cheap to revi
local-and-non-roaming requirement more cleanly than any desktop platform does.
- **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns
something like `localhost` on Android and would make every entry from a phone indistinguishable.
- **The Hetzner Cloud importer is not ported, and it is the one gap here that is not a platform limit.**
Its view model is in `DodoSSH.Client.Shell` like every other and its reader is an `HttpClient` call, so
both halves already run on a phone — unlike the `~/.ssh/config` importer beside it, which has no file to
read under scoped storage and never will. What is missing is a layout: the desktop draws a five-column
table of servers, and a phone version of that is a different screen rather than the same one narrower.
Said on the Preferences screen rather than left to be discovered.
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
confirmed to exist before anything is written to solve it.
+46 -1
View File
@@ -300,7 +300,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
> | No pins destination in the design at all | **Kept anyway.** The rail still carries Pins — `KnownHostsScreen` — because the mock has no screen for approved host keys and this application's has to stay reachable. |
> | No pins destination in the design at all | **The rail agrees with the design now.** `KnownHostsScreen` is still built and still reachable — from **Host keys** on the Keys screen's own header, which was always the second way in — but the rail's Pins row is gone. It was kept through v5b on the grounds that the mock has no screen for approved host keys, which is a reason for the screen to exist and was never a reason for a rail entry once the keychain had a door to the same place. Two rail rows landing on one screen is a rail that has to be read twice. |
> | The popover's Settings and Preferences rows, and the design's own Settings-* family of screens | **Landed in v5c.** What was two doors to one room in v5b — Settings and Preferences both opening the same bare `Preferences` screen — is now two of three doors onto their own settings pages: Settings opens General, Preferences opens Preferences, and a third row, Vaults, opens Vaults. All three are real, distinct pages inside one settings mode; see the v5c section, below. |
> | `· Org` after the user chip's name, and a `Primary` tag on a vault row in the popover | Neither. There is no organisation concept behind a vault — only the vault itself — and no vault is distinguished as primary; the popover's vault rows are the existing shown-vaults toggles, restyled. |
> | The design's titlebar, which has nowhere for a sync indicator | `SYNCED` stays, on the titlebar's right side, ahead of the window's own minimise/maximise/close buttons — the one thing this titlebar keeps that the design's own does not draw at all. |
@@ -720,3 +720,48 @@ grid of cards with a drawer — see above. The split it describes did not change
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire,
`IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected.
---
## v5c-4 — two more, asked for after living with v5b
**The session shell's host header is gone, and it is a deliberate departure from the design.**
`Terminal.dc.html` and `SFTP.dc.html` both draw a 60-pixel row above the pane carrying the address on the
left and a cross-surface button on the right, and v5b shipped it as `SessionHeader.axaml`. Both of the two
facts it held now live at the head of the sidebar beside the pane — the address as its own line, and the
button stretched across the column under it — and the pane is 60 pixels taller for it. The reasoning is the
one the design cannot see from a mock: this is a window somebody keeps a terminal open in all day, and a
full-width strip repeating an address the tab already names was the cheapest 60 pixels in the layout to give
back. `LayoutHarness.SessionScreenHeight` no longer subtracts a header, for the same reason it stopped
subtracting the retired window-wide tab strip.
**The sidebar closes, which the design has no state for.** 300 pixels of a 1081-pixel minimum is a lot to
spend on a list that is often two rows long, so `MainWindowViewModel.IsSessionSidebarOpen` folds the column
to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel
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.
+704 -29
View File
@@ -28,8 +28,9 @@ a phase had nothing left for a person to do, which is the good outcome rather th
### 1.1 No screen is sliced at the WebView's left edge · **the important one**
Open two terminals, then visit every nav rail entry in turn — Hosts, Keys, Pins, Snips, Logs — and both of
the switcher's other two segments, SFTP and S3, at the rail's own head.
Open two terminals, then visit every nav rail entry in turn — Hosts, Keys, Snips, Logs — and both of
the switcher's other two segments, SFTP and S3, at the rail's own head. The pins screen is no longer a rail
entry; reach it from **Host keys** on the Keys screen's header and check it the same way.
**Pass:** each screen draws whole, its buttons all clickable, and the nav rail stays up the left edge for
every one of them. Since v5b's chrome pass the rail is permanent furniture — it no longer collapses for
@@ -117,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.
@@ -133,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
@@ -164,12 +168,118 @@ 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
and this application's own quick-connect card was built to reach past.
### 1.10 A terminal left alone for a long time is still a terminal · **needs an hour, or a debugger**
Open a shell, leave the terminal for another screen — HOSTS, FILES, anything — and leave the application
alone for long enough that the window has been in the background for the better part of an hour. Locking the
machine or letting it sleep counts and is the easier way to get there. Come back and click the session's
tab.
**Pass:** the pane is exactly where it was and takes input straight away. If anything is shown at all it is
`Reconnecting the terminal view…` for a moment, in the second or so before the socket is back — never a
status that is still there after that.
**Both of the page's own rules here are covered by `RendererReconnectionTests`**, which runs `terminal.js`
itself in a fake browser — so a failure of this check is more likely to be the WebView behaving unlike that
fake than the page's logic being wrong. That is exactly the division: the test owns the logic, this owns the
platform.
**Failure means:** a banner that stays up is the page's retry not running. It is a `setTimeout` chain, and a
chain is what a WebView is entitled to throttle or freeze while nobody is looking at the page; `terminal.js`
answers that with wake-ups on `visibilitychange`, `focus` and `online`, none of which can be throttled,
plus a watchdog for a handshake that never finishes. A banner that flickers on and off every second or two
instead is the opposite fault — a reconnect loop, in which each attempt displaces the socket before it
through `TerminalDataPlane.UpgradeAsync`'s takeover and the displaced socket's close schedules the next.
That is what the "is this still the page's socket" guard in `connect()` exists to stop. A pane that takes no
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…*
with a bar moving under it, 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, the bar stops moving, and it reads *DodoSSH could not start:* followed by the
reason and *Press Escape to close.* Escape closes it. Remove the directory afterwards.
**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
@@ -333,6 +443,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
@@ -400,6 +578,82 @@ surface. Use the ← button, or drag the file to the local pane first.
---
## Phase 2b — The path box, and what can be done to a remote row
The rules are automated — `SftpPath.Resolve` in `RemotePathTests`, the three questions and the automatic
refresh in `ShellFlowTests`, both new shapes measured in `ScreenLayoutTests`. What is left here is what
those cannot reach: a real server's own refusals, and a soft keyboard.
Connect the SFTP page to a host first.
### 2.20 Typing a path
Click into the path in the remote pane's header, type `/var/log`, press Enter. Then type `..`, Enter. Then
`~`, Enter.
**Pass:** each one lists, the breadcrumb underneath follows, and the box ends up showing the absolute path
that was actually reached rather than what was typed.
### 2.21 Escape puts the path back
Type nonsense into the box without pressing Enter, then press Escape.
**Pass:** the box goes back to the directory the pane is showing.
### 2.22 A path the server refuses
Type a path that does not exist, and one that exists but is not readable by this account.
**Pass:** the pane stays where it was, the status line at the foot of the screen carries the server's own
message, and the box still holds what was typed so it can be corrected.
### 2.23 Rename
Select a file, press the rename button, change the name, press RENAME.
**Pass:** the listing comes back with the new name and the row still selected. Then try a name that another
file in the directory already has: **the server refuses it and says so, and nothing is overwritten** — this
is the one that matters, because the whole queue's "nothing lands at its final name until it is complete"
promise rests on the same server behaviour.
### 2.24 Move
Select a file, press the move button, type a directory — try an absolute one and a relative one — and press
MOVE.
**Pass:** the file leaves the listing and is in the directory that was typed.
Then try one across a filesystem boundary on the same host — `/tmp` to a path on a separate mount is the
usual pair. **Pass:** the server refuses with its own message about a cross-device link. This cannot be
faked in a test and is the refusal somebody will eventually meet.
### 2.25 The listing refreshes itself after an upload
Upload a file into the directory showing.
**Pass:** it appears in the remote listing without REFRESH being pressed. Then upload into a *different*
directory: nothing on screen changes. Then download a file into the folder the local pane is showing, and
that one appears on the left the same way.
### 2.26 A transfer that lands while a question is open
Start an upload into the directory showing, and while it runs select a row and press DELETE so the question
is up when the transfer finishes.
**Pass:** the listing does not move while the question is on screen, and refreshes the moment the question
is answered or cancelled.
**Failure means:** the row the question was asked about is not necessarily the row that gets acted on.
### 2.27 The phone's GO button
On Android, connect on the Files screen, type a path into the box above the breadcrumb and press GO.
**Pass:** it lists. The keyboard's own return key may or may not commit — that is the IME's choice, which is
why GO exists — but GO always must.
---
## Phase 3 — Host groups and snippets
Two synced item kinds, two ways of drawing a group — the desktop's grid of cards, one level at a time, and
@@ -1584,6 +1838,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.
@@ -1737,12 +2012,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
@@ -1750,18 +2035,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
@@ -1796,6 +2161,49 @@ worse than saying nothing: the banner exists so this is never silently wrong. If
banner is there, the session's credit window was not reset on reattach and the shell is frozen behind it —
see `TerminalWorkspace.ReplayAfterAttachAsync`.
### 11.12a Coming back to a terminal screen left alone for a long while
The same shape as 11.12 and a different trigger: rather than backgrounding the app, stay in it. With a shell
open, leave the terminal for HOSTS, FILES or MORE — which collapses the renderer to GONE, so the page is
hidden by Chromium's reckoning — and leave the phone alone for at least ten minutes with the screen off.
Then come back to the app and to the terminal.
**Pass:** the pane is there and takes input at once, or reconnects visibly within about a second of the
screen appearing. `Reconnecting the terminal view…` on the way in is fine; still being there once the
terminal has been on screen for a couple of seconds is not.
**Failure means:** the page's retry did not survive being hidden. A hidden WebView has its timers throttled
— once a minute after five minutes hidden — and a renderer that was frozen or reclaimed runs none of them,
which is why `terminal.js` does not rely on the timer alone: `visibilitychange` is the event that says the
screen is back, and it reconnects immediately rather than waiting to be asked twice. Ten minutes is chosen
to clear the five-minute threshold with room to spare.
**Not covered by either check, and worth knowing:** a socket that is *open and dead* — the connection gone
without either end noticing, which a suspended renderer can leave behind — shows no banner at all, because
`readyState` still reads OPEN and nothing on this page probes further. The symptom is a terminal that looks
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
@@ -2423,7 +2831,9 @@ script warns rather than failing when that is legitimate, which is the first rel
### 16.7 The update arrives, and the restart lands in it · **the whole point of the work**
With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours.
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours. Closing and
reopening the application does the same thing without the button: the first pass of the loop runs at launch,
so a client started after a release finds it without anybody asking.
**Pass:** the progress bar moves, the banner appears above the status bar, and — the part to actually watch
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
@@ -2437,6 +2847,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.
@@ -2482,6 +2918,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
@@ -2713,3 +3186,205 @@ Register a device, sync something, then remove the application.
**Failure means:** an empty directory is the pack id having been changed to `DodoSSH`, which puts Velopack's
install root on top of `ClientPaths.DataDirectory` and makes an uninstall delete a user's un-synced work.
This is the single reason the bundle is named `DodoSSH.Desktop.app`.
## Phase 19 — Installing the Linux client, and the things dnf does not check
The Linux counterpart of phases 16 and 18. One of their questions is off the table — there is no signing,
because nothing on this platform refuses an unsigned package, which is a distinct statement from "unsigned
is fine"; `build/linux/dodossh.spec` says what stands in for a signature, which is that the package comes
from the project's own forge over TLS and from nowhere else.
Everything else is here, and three things are here that neither other phase has. **The update does not
install itself**, so 19.8 is about watching a polkit prompt appear and saying no to it, where phases 16 and
18 watch an application replace itself. **The device key's gate is a PIN rather than a gesture**, with the
TPM's own lockout behind it — so 19.7 deliberately breaks something and then tells you how to put it back.
And **this package changes a permission on the machine**: 19.4 and 19.5 are about the udev rule that gives
the seated user the TPM, checking both that it works and that masking it undoes it completely.
What this phase is for is the set nobody else can answer. **CI builds this package and installs nothing**,
and every check in `scripts/ci-linux-rpm.sh` is a check on what the package *says* — its dependencies, its
file list, its modes. The code does better: `LinuxDeviceKeyStoreTests` runs the whole seal, unseal,
wrong-PIN and lockout path against a software TPM, and `LinuxUpdateChannelTests` covers the version
comparison and the digest. What none of them touch is `/dev/tpmrm0`, PackageKit, polkit and a real desktop
session, and the failures below are all of the shape "installs perfectly and then does not work".
Needs a Fedora 44 machine or newer with a graphical session. Build the package first:
```bash
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
-p:RestoreLockedMode=false --output /tmp/linux-x64
scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
sudo dnf install /tmp/rpm/dodossh-1.2.3-1.x86_64.rpm
```
### 19.1 It launches from the menu, and not only from a shell · **do this one first**
Log out and back in, or wait for the desktop to notice the new `.desktop` file, then start DodoSSH from the
applications menu rather than from a terminal.
**Pass:** the window opens, and the running application's icon in the dock or task list is the same entry
that launched it — one icon, not two.
**Failure means:** a second unlabelled icon appearing beside the launcher is `StartupWMClass` not matching
what Avalonia sets `WM_CLASS` to. The spec sets it to `DodoSSH`, which is the assembly name and is the same
for both channels; if Avalonia's answer has changed, `xprop WM_CLASS` on the window is what to compare
against. Nothing at all in the menu is the desktop database not having been rebuilt — `%post` runs
`update-desktop-database` and swallows its failure on purpose, so try it by hand before concluding anything.
### 19.2 The terminal draws, which is the WebKitGTK question
Connect to a host and use the shell: type, run something that scrolls, resize the window.
**Pass:** the terminal attaches within a second or two and behaves as it does on Windows.
**Failure means:** **a blank pane where everything else works is the known one**, and it is the reason
`MainWindow.OnTerminalEnvironmentRequested` exists — the GTK WebView adapter in its default mode can only
open a window of its own, so a control asked to host it in place has nothing to draw. If that method has
stopped setting `ExperimentalOffscreen`, or the vendor has withdrawn the flag, this is what it looks like.
A pane that reports a renderer timeout instead is the loopback WebSocket rather than the backend.
### 19.3 The icon is the mark, at the sizes the desktop actually asks for
Look at it in the applications menu, in the dock, and in the window switcher.
**Pass:** the accent tile and the `>_` mark, filling its square, legible at the small end.
**Failure means:** a generic icon is `Icon=` in the desktop entry not matching an installed file name — both
are the package's binary name, so they can only disagree if one was edited. A blurry small icon is the
48-pixel file missing and the 256 being scaled down; `dodossh-icon.ps1` draws four sizes and the spec
installs four.
### 19.4 The udev rule gives *this* session the TPM, and gives nobody else it · **needs a real TPM**
Installing the package should be the whole setup — no group, no administrator, no fresh login. Check it
from a graphical session on the machine itself:
```bash
getfacl /dev/tpmrm0 | grep "^user:"
id -nG | tr ' ' '
' | grep -x tss # expect nothing: you are not in the group
```
**Pass:** `getfacl` shows a `user:<you>:rw-` entry that is **not** there for any other account, and you are
not in `tss`. Now `ssh` into the same machine from elsewhere and run the same `getfacl`: the entry for you
is still the *local* session's, and a shell that is not on the seat cannot open the device — `head -c1
/dev/tpmrm0` answers "Permission denied".
**Failure means:** no ACL at all is the rule not having been applied. `udevadm test --action=change
/sys/class/tpmrm/tpmrm0 | grep uaccess` says whether the tag is being set, and the load order matters —
71 has to fall between `60-tpm-udev.rules` and `73-seat-late.rules`, which is what turns the tag into an
ACL. If the file is present and untagged, check nothing in `/etc/udev/rules.d` is masking it. **An ACL
granted to a remote session would be the serious one**, and would mean the tag had been put on `tpm` rather
than `tpmrm` or the mode widened instead — neither of which this rule does.
### 19.5 Masking the rule puts the machine back the way it was
```bash
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=tpmrm --action=change
```
Log out and back in, then open the security settings page.
**Pass:** registering a device key is **not** offered, and unlocking asks for the passphrase every time —
the same behaviour as a machine with no TPM. `udevadm test` reports the file as "masked by previous entry".
**Failure means:** still being offered it means `LinuxDeviceKeyStore.IsSupported` answered from the presence
of the device node rather than from opening it. Accepting that offer would put a wrap on the server that
nothing can ever open, and the failure would arrive at the next unlock rather than now.
**Then undo it** — `sudo rm /etc/udev/rules.d/71-dodossh-tpm.rules` and reload — before 19.6.
### 19.6 The PIN unlocks, and the TPM is what checks it
Register a device key from the security settings page and choose a PIN. Lock the vault and unlock it again.
**Pass:** registering asks for a PIN twice and shows no other prompt. Unlocking asks for it once and opens
the vault. Cancelling that prompt lands on the passphrase screen, which works.
**Failure means:** a PIN prompt that opens the vault whatever you type would mean the unseal is not reaching
the TPM at all, which is the one failure that would make this whole feature decorative.
### 19.7 A wrong PIN gets nothing, and the TPM stops answering
With a device key registered, lock the vault and enter a wrong PIN. Do it several times.
**Pass:** each attempt lands on the passphrase screen. After a handful, even the **correct** PIN lands
there too — the TPM has entered lockout and is refusing everyone, which is the point.
**Failure means:** wrong PINs that never stop being accepted for the attempt is the `NoDA` attribute having
been set on the sealed object, which would make a four-digit PIN guessable at full speed by anything holding
the blob and the machine. `LinuxDeviceKeyStoreTests` asserts this against a software TPM; this is the same
assertion against real firmware, where the lockout is real and recovery takes hours.
**Then put it back:** `sudo tpm2_dictionarylockout --clear-lockout`, or wait out the recovery time. Note
which, because a user who trips this will ask — and the honest answer is that it needs an administrator.
### 19.8 An update is noticed, fetched, and installed by PackageKit and not by DodoSSH
With the nightly installed, wait for a newer one to be published — or press **CHECK NOW** on
PREFERENCES → UPDATES once there is one.
**Pass:** the banner names the new version and downloads it. Pressing install raises the desktop's own
*"Authentication is required to install software"* prompt, drawn by polkit and not by DodoSSH. Accepting it
installs the package; declining it changes nothing and leaves the vault unlocked and every shell open.
DodoSSH does **not** restart, and the version on the preferences screen does not change until you close and
reopen it.
**Failure means:** an update that is offered and then refused by PackageKit is the version comparison
disagreeing with rpm — `rpm -q --queryformat '%{VERSION}-%{RELEASE}' dodossh-nightly` against the
`rpmVersion` in the release's `linux-nightly.json` is the comparison being made, and `RpmVersionTests`
covers the algorithm. A message naming PackageKit means it is not installed; it is a weak dependency, so a
minimal install legitimately lacks it. **The vault locking or shells closing when you decline is the
serious one** — it means `ApplyingEndsTheProcess` has been set true, which punishes somebody for saying no.
### 19.9 A tampered package is refused before PackageKit ever sees it
Not reachable without editing the feed, so this is the one to reason about rather than perform unless you
are changing that code: the manifest's `sha256` is checked after the download and the file is deleted if it
does not match.
**Pass:** `~/.local/share/dodossh.nightly/updates/` holds a package only when its digest matched.
**Failure means:** a leftover `.rpm` there after a failed update is a file somebody could reasonably install
by hand, named as though the application had endorsed it.
### 19.10 The nightly installs beside the release rather than over it
With the release package installed, install the nightly one too.
**Pass:** `dnf` installs it as a second package rather than as an upgrade. Both entries appear in the menu,
named *DodoSSH* and *DodoSSH Nightly*. Both run at once. Signing into the nightly asks for the passphrase
again and appears on the account as a separate device.
**Failure means:** `dnf` offering to *upgrade* rather than install is the two packages sharing a name. A
nightly that opens the release's vault already unlocked is the profile separation gone — that comes from
`DodoChannel=nightly` reaching `ClientPaths`, not from the packaging, so check
`~/.local/share/dodossh.nightly` exists rather than looking at the spec.
### 19.11 Nothing in the installed tree is writable by anyone but root
```bash
find /opt/DodoSSH ! -user root -o -perm -o+w
```
**Pass:** no output.
**Failure means:** any line here is a local account being able to replace the SSH client every other account
on the machine runs, which for an application holding a team's credentials in plaintext is the whole trust
model walked around rather than attacked. `ci-linux-rpm.sh` refuses to produce such a package and the spec
sets every mode absolutely, so a hit here means one of those two was changed — it cannot arrive by accident
from a publish directory.
### 19.12 Removing it leaves the vault, and leaves the other channel alone
Register a device, sync something, then `sudo dnf remove dodossh`.
**Pass:** `~/.local/share/dodossh` still holds the cache and the outbox. `/opt/DodoSSH` is gone entirely.
`/opt/DodoSSH.Nightly` and its menu entry are untouched.
**Failure means:** a missing cache is `ClientPaths` having been pointed somewhere under `/opt`, which would
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
something wrote into the install directory at runtime and would survive a reinstall.
+37
View File
@@ -388,6 +388,19 @@ agent of our own plus ProxyJump covers the real use cases.
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
**That suite has an intermittent `The connection was closed by the remote host`**, on whichever test
connects first, within tens of milliseconds. Seen in CI and reproducible locally. *Mitigated, not
solved:* `SshServerFixture` now raises sshd's `MaxStartups` from its compiled-in `10:30:100`, which
refuses connections at random past ten unauthenticated ones in flight — reachable because xUnit runs
test classes in parallel and most of them connect. The fixture comment carries the full argument and
is explicit that the cure is unproven.
**And the reason it is unproven is a measurement trap worth not falling into twice.** Docker
throughput on the Windows development machine swings enough to swamp the effect: the identical
unmodified suite ran 85/85 clean and, an hour later, failed 13 runs out of 15. Any before/after flake
comparison taken there is noise. Measure this class of thing in CI, or make the server say why —
raise sshd's `LogLevel`, disable Ryuk so the container outlives the run, and read `docker logs`.
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
@@ -699,6 +712,30 @@ The lasting hazard is the first paragraph and not the fix. Any change to a share
to a lock file this repository cannot verify from a machine without the Android workload, and it will go
on being noticed later than every other one.
**A lock file can go stale with nothing in this repository changing, because `Microsoft.NET.ILLink.Tasks`
is versioned by the SDK and `global.json` lets the SDK float.** The reference is implicit — nothing in any
`.csproj` asks for it — and its version tracks the runtime patch band, while `global.json` pins only
`10.0.100` with `rollForward: latestMinor`. So `setup-dotnet` installs whatever the newest 10.x SDK is on
the day, and the moment that SDK's band moves, locked-mode restore stops:
```
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
from [10.0.10, ) to [10.0.11, ).
```
It named `DodoSSH.Client.Android`, `DodoSSH.Contracts` and `DodoSSH.Crypto` — the three lock files that
carry the entry — on a commit that touched none of them and no dependency at all.
The fix is `--force-evaluate` on those three, **from a machine whose SDK is at least as new as the
runner's**, which is the part that is easy to get wrong: a `--force-evaluate` from an older SDK rewrites
the lock at the older version, changes nothing, and looks like it worked. Check `dotnet --version` against
the version in the error before believing a regeneration.
This will recur on every SDK patch that moves the band. It is the accepted cost of letting the SDK float:
the alternative is pinning an exact SDK in `global.json`, which trades a recurring lock-file bump for a
recurring toolchain bump and makes every contributor install one specific SDK. Neither is free, and this
repository has chosen the floating side deliberately.
**.NET for Android cannot be built on a musl host, and this project's runner is Alpine. Every message the
toolchain produces on the way to saying so names a missing file that is present.** Three CI rounds went
into this and the first two fixed symptoms, so the messages are worth reading in the order they arrive.
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env bash
#
# Turns a published linux-x64 tree into an RPM, and then checks the RPM rather than the exit code.
#
# Called from two jobs in .github/workflows/ci.yml and written once for the reason the four copies of
# "ensure node and git" in that file are not: those cannot be factored out — a composite action would
# need the checkout it exists to unblock — and this can. The build job packages the release-shaped
# RPM and throws it away, exactly as it does for Windows and macOS; the desktop job packages the
# nightly-shaped one and publishes it. Everything that differs between those two is an argument.
#
# It is also runnable at a desk, which is the point of it being a script:
#
# dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
# --configuration Release --runtime linux-x64 --self-contained true \
# -p:RestoreLockedMode=false --output /tmp/linux-x64
# scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
#
# Nothing here holds a secret and nothing here uploads anything. Publishing is the calling job's, and
# ADR 0013 rule 3 is what decides which channel may do it.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
payload=""
version=""
channel="release"
out=""
while [ $# -gt 0 ]; do
case "$1" in
--payload) payload="$2"; shift 2 ;;
--version) version="$2"; shift 2 ;;
--channel) channel="$2"; shift 2 ;;
--out) out="$2"; shift 2 ;;
*) echo "Unknown argument: $1" >&2; exit 2 ;;
esac
done
for required in payload version out; do
if [ -z "${!required}" ]; then
echo "--$required is required." >&2
exit 2
fi
done
case "$channel" in
release|nightly) ;;
*) echo "--channel must be release or nightly, not '$channel'." >&2; exit 2 ;;
esac
# The apphost, named rather than globbed. A publish that produced no apphost at all would otherwise
# package happily into an RPM whose /usr/bin symlink points at nothing, and the first thing anybody
# would learn about it is a shell saying "No such file or directory" for a file that is right there.
if [ ! -s "$payload/DodoSSH" ]; then
echo "There is no apphost at $payload/DodoSSH, so there is nothing to package." >&2
ls -la "$payload" >&2 || true
exit 1
fi
# ◆ THE FOUR THINGS THAT DIFFER BETWEEN THE CHANNELS, AND WHY IT IS FOUR AND NOT ONE.
#
# This is ADR 0013 rule 9's table, in the spelling this platform uses. The package name keeps dnf from
# treating one as an upgrade of the other; the directory under /opt keeps the two trees apart on disk;
# the /usr/bin name and the desktop entry keep them apart in front of a person, who would otherwise
# have two identical launchers and no way to tell which was which.
#
# The fifth separation — the profile directory holding the vault cache, the outbox and the device key
# — is not here and must not be, because it belongs to the build rather than to the package: the
# nightly is published with DodoChannel=nightly, and ClientPaths reads that to suffix the directory.
# Doing it here as well would be a second answer to a question that already has one.
if [ "$channel" = nightly ]; then
name=dodossh-nightly
dir=DodoSSH.Nightly
bin=dodossh-nightly
title="DodoSSH Nightly"
else
name=dodossh
dir=DodoSSH
bin=dodossh
title="DodoSSH"
fi
# ◆ SEMVER IS NOT AN RPM VERSION, AND THE TRANSLATION IS THE ONE PLACE THIS CAN GO QUIETLY WRONG.
#
# rpm forbids `-` in a version, which is exactly the character SemVer uses to introduce a prerelease.
# MinVer's answer on main is 0.0.1-alpha.0.143, so the naive move — strip the suffix — would give
# every nightly the version 0.0.1 and make each one look like a reinstall of the last.
#
# `~` is rpm's own answer and has been since 4.10: it sorts *before* the empty string, so
# 0.0.1~alpha.0.143 is older than 0.0.1 the way alpha.143 is older than the release. Heights keep
# rising within a floored version and the first real tag moves past all of them, which is the same
# ordering property the Velopack channels have and is worth having on both.
#
# Build metadata after `+` is dropped rather than translated. It is the one part of a SemVer string
# that is defined as not participating in ordering, so keeping it would put a character rpm has no
# rule for into the field ordering is done on.
rpm_version="${version%%+*}"
rpm_version="${rpm_version/-/\~}"
case "$rpm_version" in
*-*)
echo "The version $version translates to $rpm_version, which still contains a hyphen." >&2
echo "rpm cannot order a version with one in it; this needs a rule rather than a retry." >&2
exit 1
;;
esac
# Always 1. The release field distinguishes several packagings of one upstream version, and there is
# only ever one packaging here — the version already carries the build height, because upstream and
# packager are the same repository.
rpm_release=1
echo "==> $name $rpm_version-$rpm_release.x86_64, from $payload"
# ◆ rpmbuild ON A MUSL HOST, WHICH IS FINE, AND WHY IT IS WORTH SAYING SO.
#
# The runner is Alpine. Nothing about that shows up in the package: rpmbuild composes an archive and
# some metadata, and the one part of it that reads the payload — elfdeps, which the spec leaves on for
# requires — parses ELF headers with libelf rather than loading anything, so it reads x86_64 glibc
# binaries on a musl host correctly. Building in a Fedora container was the alternative and would
# have meant `docker cp`-ing a 130 MB publish tree in and the package back out, because this runner is
# itself a container and a bind mount resolves on the daemon's host rather than here — see the note on
# the android job for the hour that cost.
if ! command -v rpmbuild >/dev/null 2>&1; then
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
echo "==> installing rpm"
if command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache rpm
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y rpm-build
elif command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends rpm
else
echo "No apk, dnf or apt-get here, so rpmbuild cannot be installed from inside the job." >&2
echo "Add rpm to the runner's image; the Linux package cannot be built without it." >&2
exit 1
fi
fi
rpmbuild --version
# ◆ THE LIST OF PRIVATE SONAMES, WORKED OUT FROM THE TREE RATHER THAN WRITTEN DOWN.
#
# build/linux/dodossh.spec turns rpm's automatic provides off for everything under /opt, because the
# libraries there are this application's private copies and have no business satisfying anybody
# else's dependency. The requires side has to be told the same thing about the same set, or a bundled
# library that links another bundled library asks for a soname nothing provides and the package
# becomes uninstallable on every host — which .NET's own libmscordbi.so, linking libmscordaccore.so,
# does on every publish.
#
# Derived here because the set belongs to the payload. A list in the spec would be Microsoft's and
# SkiaSharp's internal file names, maintained by hand, going stale on the first runtime bump — and
# going stale in the direction that produces a package nobody can install.
#
# Only the top level is scanned, which is where a .NET publish puts its native libraries. The dots
# are escaped because this becomes a regular expression; nothing else in a soname is a metacharacter.
#
# A shell glob rather than `find -printf`, which is GNU find's and not busybox's — the runner is
# Alpine, where that flag is a usage message and an empty list, which would silently disable the whole
# exclusion and put the uninstallable package back.
bundled="$(
for library in "$payload"/lib*.so*; do
[ -f "$library" ] && basename "$library"
done |
sed 's/\./\\./g' |
sort -u |
paste -sd '|' -
)"
if [ -z "$bundled" ]; then
echo "There are no native libraries at the top of $payload." >&2
echo "A self-contained .NET publish always has some, so this is a publish that went wrong" >&2
echo "rather than a package with nothing to exclude." >&2
exit 1
fi
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$out"
# --target rather than trusting the host's arch, because the host's arch here is whatever the runner
# happens to be and the payload's is decided by the RID the publish asked for. They agree today and
# an aarch64 runner would silently make them disagree.
#
# `dist` is emptied deliberately. It is the %{?dist} tag that puts .fc42 in a filename, and it names
# the distribution the package was *built* on — which for a self-contained tree built on Alpine would
# be both meaningless and, if rpm guessed something plausible, actively misleading.
rpmbuild -bb "$repo_root/build/linux/dodossh.spec" \
--target x86_64 \
--define "_topdir $work" \
--define "_rpmdir $out" \
--define "dist %{nil}" \
--define "dodo_name $name" \
--define "dodo_version $rpm_version" \
--define "dodo_release $rpm_release" \
--define "dodo_dir $dir" \
--define "dodo_bin $bin" \
--define "dodo_title $title" \
--define "dodo_payload $payload" \
--define "dodo_bundled $bundled" \
--define "dodo_icons $repo_root/src/DodoSSH.Client.App/Assets" \
--define "dodo_udev $repo_root/build/linux/71-dodossh-tpm.rules" \
--define "dodo_licence $repo_root/LICENSE"
# rpmbuild writes into <_rpmdir>/<arch>/, and every caller wants the file rather than the tree.
rpm_file="$out/x86_64/$name-$rpm_version-$rpm_release.x86_64.rpm"
if [ ! -s "$rpm_file" ]; then
echo "rpmbuild reported success and there is no package at $rpm_file." >&2
find "$out" >&2 || true
exit 1
fi
mv "$rpm_file" "$out/"
rmdir "$out/x86_64" 2>/dev/null || true
rpm_file="$out/$(basename "$rpm_file")"
echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
# ◆ AND NOW THE PART THAT IS NOT PACKAGING: ASKING THE PACKAGE WHAT IT SAYS ABOUT ITSELF.
#
# Everything above can succeed and still produce something nobody can install, and none of the ways
# that happens are visible in an exit code. Each check below is one of them.
#
# ◆ EVERY READER IN A PIPELINE HERE MUST CONSUME ALL OF ITS INPUT, AND THAT IS NOT A STYLE RULE.
#
# `set -o pipefail` at the top of this file means a pipeline's status is the rightmost non-zero one.
# A reader that stops early — `grep -q`, `head -n` — closes the pipe while the writer is still
# writing, the writer dies of SIGPIPE with status 141, and pipefail hands that 141 to the `if`. So a
# check that found what it was looking for reports the opposite, and a diagnostic printed on the way
# to `exit 1` kills the script under `set -e` before it gets there.
#
# It is a threshold rather than a constant, which is what made it look like somebody else's bug: the
# writer only blocks once it has more than a pipe buffer left to write, about 64 KB on Linux. `$files`
# is one line per packaged file, so this passed for as long as the published tree was small and then
# failed on the commit that pushed the path text past 64 KB — reporting `/opt/DodoSSH/DodoSSH` missing
# from a package that plainly contained it, and exiting 141 rather than the 1 it meant to.
#
# Hence `grep pattern >/dev/null` rather than `grep -q pattern`, which has the same exit status and
# reads to the end; and `awk 'NR<=40'` rather than `head -40`, which prints the same lines and reads
# to the end. Nothing here is allowed to stop early, and `|| true` is not the fix — it would hide a
# real failure of the writer alongside the imaginary one.
requires="$(rpm -qp --requires "$rpm_file")"
provides="$(rpm -qp --provides "$rpm_file")"
files="$(rpm -qp --list "$rpm_file")"
# 1. The bundled-soname trap the spec's provides exclusion sets. If some library under /opt ever
# gains a DT_NEEDED on another library under /opt, elfdeps will put that soname in the requires
# while the exclusion keeps it out of the provides, and the result is a package no host on earth
# can satisfy. Nothing about it looks wrong until an install fails, so it is checked here against
# the actual contents rather than assumed.
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
unsatisfiable=""
for soname in $carried; do
if printf '%s\n' "$requires" | grep -F "$soname(" >/dev/null; then
unsatisfiable="$unsatisfiable $soname"
fi
done
if [ -n "$unsatisfiable" ]; then
echo "This package requires sonames it also carries and does not provide:$unsatisfiable" >&2
echo "That is unsatisfiable on every host, and it means the requires exclusion computed above" >&2
echo "no longer matches what rpm generated — a bundled library outside the top level, or an rpm" >&2
echo "that has stopped honouring __requires_exclude. See build/linux/dodossh.spec." >&2
exit 1
fi
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring
# the macro would break it silently and in the direction nobody looks.
if printf '%s\n' "$provides" | grep -E '^lib.*\.so' >/dev/null; then
echo "This package advertises bundled libraries to the system:" >&2
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2
echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2
exit 1
fi
# 3. elfdeps having actually run. If it silently did nothing — a runner image whose rpm was built
# without libelf would do exactly that — the package would carry no glibc requirement at all and
# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a
# self-contained .NET tree cannot fail to have.
if ! printf '%s\n' "$requires" | grep '^libc\.so\.6' >/dev/null; then
echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
printf '%s\n' "$requires" >&2
exit 1
fi
# 4. The four files a person interacts with, named individually. The tree under /opt is thousands of
# entries and its absence would be caught by size alone; these are the small ones whose absence
# would leave a package that installs, occupies 130 MB and puts nothing on the menu.
for expected in \
"/opt/$dir/DodoSSH" \
"/usr/bin/$bin" \
"/usr/share/applications/$bin.desktop" \
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
do
if ! printf '%s\n' "$files" | grep -xF "$expected" >/dev/null; then
echo "The package was built and $expected is not in it." >&2
printf '%s\n' "$files" | awk 'NR<=40' >&2
exit 1
fi
done
# 5. Nothing world-writable, which on this package is a security property rather than a lint rule.
# The spec sets every mode explicitly and says why; this is the check that it worked, because the
# failure it guards against arrives by inheritance from a publish directory rather than by anybody
# changing this. A world-writable /opt/DodoSSH lets any local account replace the SSH client every
# other account on the machine runs.
#
# Symlinks are excluded because they are always 777 on Linux and the mode means nothing; what the
# link points at is what has permissions.
writable="$(
rpm -qp --queryformat '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm_file" |
grep -v '^l' |
awk '$1 ~ /^.{8}w/ { print }'
)"
if [ -n "$writable" ]; then
echo "This package contains world-writable files:" >&2
printf '%s\n' "$writable" >&2
echo "Any local account could replace them. See the mode block in build/linux/dodossh.spec." >&2
exit 1
fi
echo "==> requires:"
printf '%s\n' "$requires" | sed 's/^/ /'
echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so."
# ◆ THE MANIFEST, WHICH IS WHAT AN INSTALLED CLIENT READS AND NOT WHAT A PERSON READS.
#
# The same arrangement the android job publishes, for the same reason its comment gives: a release's own
# JSON is a large document that changes shape when the forge is upgraded, and the client fetches this on a
# six-hourly timer. A small file this repository writes is a contract; a large one the forge writes is not.
#
# ◆ THE VERSION IN HERE IS THE RPM VERSION, AND THAT IS THE WHOLE POINT OF THE FILE.
#
# LinuxUpdateChannel compares with rpmvercmp, because rpm is what will accept or refuse the install — the
# same argument AndroidChannelManifest makes for versionCode. Publishing the SemVer instead would mean the
# client comparing one thing and the package manager another, and they disagree exactly where it matters:
# 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are the same build, and only the second one sorts below 0.0.1.
#
# The SemVer goes in too and is used for nothing but the banner. A person asked which nightly they are on
# should be able to answer with the string the preferences screen prints, and that screen reads the
# assembly.
#
# ◆ AND THE DIGEST, WHICH THE OTHER TWO CHANNELS HAVE NO NEED OF.
#
# Velopack verifies what it downloads and Android refuses a package whose signature does not match the
# installed one. Nothing about an RPM fetched over HTTPS and handed to PackageKit checks that it is the
# file this release published — TLS authenticates the forge and says nothing about the bytes surviving a
# proxy, a cache or a partial write. So the manifest names the digest and the client refuses anything else.
#
# It is not a signature and must not be read as one: it is written by the same job that writes the package,
# so it proves integrity and not provenance. Provenance here is that the address is a constant pointing at
# the project's own forge — ADR 0013 rule 4 — and a signed repository is what would improve on it.
manifest="$out/linux-$channel.json"
digest="$(sha256sum "$rpm_file" | cut -d' ' -f1)"
cat > "$manifest" <<JSON
{
"rpmVersion": "$rpm_version-$rpm_release",
"version": "$version",
"rpm": "$(basename "$rpm_file")",
"sha256": "$digest"
}
JSON
echo "==> $(basename "$manifest"):"
sed 's/^/ /' "$manifest"
# The lines the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, so the
# desk use above still tells you where the file went.
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "rpm=$rpm_file"
echo "rpmName=$(basename "$rpm_file")"
echo "manifest=$manifest"
echo "manifestName=$(basename "$manifest")"
} >> "$GITHUB_OUTPUT"
fi
echo "$rpm_file"
+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.
@@ -436,6 +602,31 @@
<Setter Property="Fill" Value="{StaticResource Live}" />
</Style>
<!--
Amber, and it does not contradict the remark above. That one says green is a fact about a host rather
than an accent, and this is the colour for a fact that is not settled yet: green is what is true, purple
is what you can press, and a connection still being made is neither. The palette's own rule gives amber
to the caveat worth reading, which is exactly what this is.
Only the tab strips use it, and only for a tab with no shell behind it yet — the same amber, from the
same brush, as the track and the running step on the connecting screen, so that a tab and the screen it
opens agree about what is happening. See TerminalScreen.axaml.
-->
<Style Selector="Ellipse.dot.connecting">
<Setter Property="Fill" Value="{StaticResource Warn}" />
</Style>
<!--
Red for a shell whose connection broke, splitting the grey that used to cover every dead tab. A shell
somebody exited and a shell that was cut off are the two dead states with the least in common, and on a
phone the difference matters more than it does on a desk: a mobile link drops on its own, and the tab
that went quiet while the screen was off is the one worth dialling again. See
TerminalTabViewModel.IsDropped.
-->
<Style Selector="Ellipse.dot.dropped">
<Setter Property="Fill" Value="{StaticResource Danger}" />
</Style>
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
<Style Selector="TextBlock.mono">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
@@ -448,10 +639,29 @@
<Setter Property="FontSize" Value="9.5" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!--
◆ Wraps, like .body below it, and the omission was doing real damage rather than none.
A TextBlock that neither wraps nor trims does not shrink to fit and does not report that it did not:
it lays out at its full single-line width and the parent clips whatever runs past the edge. So a
.detail long enough to overflow ended mid-word against the screen, with no ellipsis to say anything
was missing — "A machine to connect to, filed under a group if you" on the add sheet, which reads as
a sentence somebody forgot to finish rather than as text that was cut.
It is a style rather than an attribute on the ones that overflow today, because which of the fifty-odd
.detail blocks overflows is a function of the device's width and the user's font scale, not of the
string: every one of them is a candidate at 320dp that is fine at 411dp, and fixing the handful that
happen to be visibly wrong on a Pixel 5 would leave the rest to be found one narrow phone at a time.
The nine that genuinely want one line — list rows and log lines, where a wrapped detail would push the
row's own height around — now say TextWrapping="NoWrap" beside their TextTrimming, which is the pairing
that was always meant and was previously being had by accident.
-->
<Style Selector="TextBlock.detail">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.body">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
@@ -48,7 +48,12 @@
the refusal has no continue button here either.
-->
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
<!--
Nine rows since the disconnect strip arrived; it is row 7, between the listing and the action bar, so
that what it says is next to the buttons it replaces the point of. Auto and empty except while the
session has been lost, so an ordinary browse pays nothing for it.
-->
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto,Auto">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
@@ -78,7 +83,13 @@
</Grid>
<!-- ============ opening one ============ -->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding !IsConnected}">
<!--
ShowsRemoteInvitation rather than !IsConnected, which stopped being the same question when a
connection could be lost rather than only closed: a session that went away leaves its listing on
screen, and this card springing back over it would hide the one thing the strip below is offering to
put the user back into. See TransfersViewModel.ShowsRemoteContents.
-->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding ShowsRemoteInvitation}">
<StackPanel Spacing="10">
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
@@ -174,9 +185,48 @@
</StackPanel>
</Border>
<!-- ============ ◆ typing where to go ============ -->
<!--
The breadcrumb below reaches only what is under the directory already listed, so crossing a host's
filesystem was a tap and a round trip a level and a path somebody already knew could not be entered at
all. This is the way in for one that is: absolute, relative to the directory showing, or starting at
~. See SftpPath.Resolve.
GO is a real button rather than only the keyboard's own action key, and that is a phone decision: a
software keyboard's return key is whatever the IME decided to draw, this application does not set the
IME action, and a path box whose only commit was a key that may say "newline" would be a box nothing
could be submitted from. The Enter binding stays for the hardware keyboards this head does meet.
-->
<Grid Grid.Row="3" ColumnDefinitions="*,8,Auto" Margin="14,0,14,6"
IsVisible="{Binding ShowsRemoteContents}">
<TextBox Grid.Column="0" Classes="field" Text="{Binding RemotePathDraft}"
PlaceholderText="/path/to/somewhere">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding GoToTypedRemotePathCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Classes="secondary" Height="44" Padding="16,0" Content="GO"
Command="{Binding GoToTypedRemotePathCommand}" />
</Grid>
<!--
◆ How many are gathered up, and the one gesture that gathers or releases them all. Only while
something is — an always-present row would spend 30 pixels of a listing on a count of zero.
-->
<Grid Grid.Row="3" ColumnDefinitions="Auto,*" Margin="14,0,14,6" VerticalAlignment="Bottom"
IsVisible="{Binding HasCheckedRemote}">
<Button Grid.Column="0" Classes="secondary" MinHeight="36" Padding="12,0"
Command="{Binding ToggleAllRemoteCommand}">
<TextBlock Classes="label" FontSize="9" Text="ALL / NONE" />
</Button>
<TextBlock Grid.Column="1" Classes="mono" FontSize="12" Margin="10,0,0,0"
VerticalAlignment="Center" Foreground="{StaticResource AccentText}"
Text="{Binding CheckedRemoteLabel}" />
</Grid>
<!-- ============ where we are ============ -->
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
Command="{Binding RemoteUpCommand}">
@@ -215,7 +265,7 @@
answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as
"this host has nothing pinned".
-->
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
<ScrollViewer Grid.Row="5" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
<ItemsControl.ItemsPanel>
@@ -240,9 +290,9 @@
</ScrollViewer>
<!-- ============ the listing ============ -->
<Panel Grid.Row="5">
<Panel Grid.Row="6">
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
<TextBlock Classes="body" IsVisible="{Binding ShowsRemoteInvitation}" Margin="24,12"
VerticalAlignment="Top" Text="{Binding Status}" />
<!--
@@ -259,7 +309,7 @@
selection navigate would send a single desktop click walking into a directory.
-->
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
Background="Transparent" BorderThickness="0" IsVisible="{Binding IsConnected}"
Background="Transparent" BorderThickness="0" IsVisible="{Binding ShowsRemoteContents}"
Tapped="OnRemoteEntryTapped">
<ListBox.Styles>
<Style Selector="ListBoxItem">
@@ -276,15 +326,27 @@
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<Border MinHeight="52" Padding="10,7">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<!--
◆ The tick, which on this head is the only multi-selection there can be: a phone has no
ctrl-click and no shift-click. It is not the list's selection — that stays "the row I am
looking at", which RENAME, MOVE and PERMS act on one at a time — and this is "the rows I
have gathered up", which SAVE and DELETE take. See RemoteEntryRowViewModel.IsChecked.
Its own 40-pixel column so a thumb aimed at it does not open the directory beside it: the
row's own tap navigates, which is this head's one behaviour the desktop does not share.
-->
<CheckBox Grid.Column="0" Width="40" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}" Click="OnRemoteRowChecked" />
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
VerticalAlignment="Center" FontSize="12" Text="▸"
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
VerticalAlignment="Center" FontSize="12" Text="·"
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
<StackPanel Grid.Column="2" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7">
@@ -295,7 +357,7 @@
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Modified}"
<TextBlock Grid.Column="3" Classes="detail" Text="{Binding Modified}"
VerticalAlignment="Center" />
</Grid>
</Border>
@@ -304,9 +366,50 @@
</ListBox>
</Panel>
<!-- ============ what the screen says once the session has stopped ============ -->
<!--
The phone's half of the desktop's SessionEndedBar, and it binds this screen's own view model rather
than the shell's — this control's DataContext is TransfersViewModel, where the desktop's strip sits in
MainWindow beside a status bar and can ask which surface it is on. The facts are the same two:
TransfersViewModel.LostConnection and the reconnect that goes back to the directory showing.
A phone needs it more than a desk does, and for the reason its terminal does: a mobile link drops on
its own and the screen goes off, so this session is discovered later with a listing on it and no
account of why nothing works. The listing stays deliberately — see ShowsRemoteContents.
-->
<Border Grid.Row="7" IsVisible="{Binding IsConnectionLost}" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Danger}" BorderThickness="0,1,0,0" Padding="12,10">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Classes="dot" Classes.dropped="True" Width="7" Height="7" VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="9" Text="DISCONNECTED" />
</StackPanel>
<!--
Two lines maximum, for the reason the terminal's strip caps its own: a transport error can hand
over whatever the operating system called it, and a strip that grew to fit one would eat the
listing above it on a screen that has none to spare.
-->
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
Text="{Binding LostConnection}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding ReconnectCommand}">
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
</Button>
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding DisconnectCommand}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
</Button>
</StackPanel>
</StackPanel>
</Border>
<!-- ============ what to do with the chosen entry ============ -->
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
<Border Grid.Row="6" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
<Border Grid.Row="8" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
<StackPanel Spacing="9">
@@ -328,6 +431,105 @@
</StackPanel>
</Border>
<!--
◆ Renaming and moving, in the place the deletion question already occupies and never at the same
time as it. One form for both — see RemoteMoveRequest for why the two questions share a shape and
differ only in their words — and no danger colours on it, because a rename is undone by renaming
back and the deletion above is the one thing here that nothing can walk back.
-->
<Border IsVisible="{Binding IsMovingRemote}" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
Padding="13,11">
<StackPanel Spacing="6">
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemoteMove.Question}" />
<TextBlock Classes="detail" TextWrapping="Wrap" Text="{Binding PendingRemoteMove.FullPath}" />
<TextBox Classes="field" Text="{Binding RemoteMoveTarget}"
PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConfirmMoveRemoteCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBlock Classes="body" Text="{Binding PendingRemoteMove.Note}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="primary" Height="44"
Content="{Binding PendingRemoteMove.ConfirmLabel}"
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveRemoteCommand}" />
</Grid>
</StackPanel>
</Border>
<!--
◆ The mode, as nine boxes and the number they add up to — in the place the other two questions
already occupy and never at the same time as either. See RemotePermissionsRequest for why it is
boxes rather than a field to type 755 into, and TransfersScreen.axaml for the desktop's own copy
of the same form.
Compact rather than a second layout: the three columns are the mode's own three triples, and at
360dp they are exactly what fits. The octal is printed underneath as it changes, which is what
lets somebody who came here to type chmod check the boxes they just ticked.
-->
<Border IsVisible="{Binding IsChangingRemotePermissions}" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
Padding="13,11">
<StackPanel Spacing="6">
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemotePermissions.Question}" />
<TextBlock Classes="detail" TextWrapping="Wrap"
Text="{Binding PendingRemotePermissions.FullPath}" />
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,44,44,44" Margin="0,2">
<TextBlock Grid.Row="0" Grid.Column="1" Classes="detail" Text="read"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="2" Classes="detail" Text="write"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="3" Classes="detail"
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="12" Text="owner"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="12" Text="group"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="12" Text="others"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
</Grid>
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource AccentText}"
Text="{Binding PendingRemotePermissions.Preview}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" Content="APPLY"
Command="{Binding ConfirmRemotePermissionsCommand}"
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelRemotePermissionsCommand}" />
</Grid>
</StackPanel>
</Border>
<!--
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
@@ -349,7 +551,7 @@
Foreground="{StaticResource Accent}"
Background="{StaticResource Raised}" />
<TextBlock Classes="detail" Text="{Binding Progress}"
TextTrimming="CharacterEllipsis" />
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
@@ -386,20 +588,44 @@
save picker names one destination. CanDownload is the desktop's own flag for the same question —
a file is selected and something is connected — and is reused rather than restated here.
-->
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
IsEnabled="{Binding CanDownload}" />
</Grid>
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
Command="{Binding DisconnectCommand}" />
<!--
◆ The two that put a file right rather than taking it away, above the row DELETE is on and away
from it. Both open a form in place of these buttons — see the card above — because a phone has
nowhere to put a text field except where something else already was.
-->
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="RENAME"
Command="{Binding RenameRemoteCommand}" IsEnabled="{Binding CanRenameRemote}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}" />
</Grid>
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
<!--
◆ PERMS beside DELETE rather than on the row above, which is full: it is the fourth thing that can
be done to a chosen row and the third that puts one right. Dark on a bucket, where an object has an
access policy rather than nine bits — see CanChangeRemotePermissions.
-->
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="PERMS"
Command="{Binding ChangeRemotePermissionsCommand}"
IsEnabled="{Binding CanChangeRemotePermissions}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="DELETE"
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
</Grid>
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CLOSE"
Command="{Binding DisconnectCommand}" />
<Panel Grid.Column="2" />
</Grid>
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
@@ -35,6 +35,15 @@ internal sealed partial class FilesScreen : UserControl
/// read its size the same gesture as fetching it.
/// </para>
/// </remarks>
/// <summary>Tells the screen a row's tick moved, so the count above the listing keeps up.</summary>
/// <remarks>
/// The desktop's own handler, for the reason given there: the count is a property of the collection
/// rather than of any row, and each listing replaces every row it has. See
/// <c>TransfersViewModel.RemoteChecksChanged</c>.
/// </remarks>
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) =>
(DataContext as TransfersViewModel)?.RemoteChecksChanged();
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
{
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
@@ -49,12 +49,29 @@
<TextBlock Classes="title" Text="UNKNOWN HOST KEY" FontSize="13" />
<!--
◆ The port and the full stop are one Run, and that is the whole of the fix.
Avalonia puts a space between adjacent Runs, which is what makes "First contact with", the
host and the separator read as a sentence without any of them carrying padding. The trailing
Run began with a full stop, so it got that space too and the sheet said
First contact with 10.0.2.2 · 2222 . Nothing in this keychain…
— a gap before the stop, on the one dialogue in this application whose entire job is to be
read carefully before somebody answers it. StringFormat welds the stop to the number inside a
single Run, where no inter-Run space can get in, and the sentence that follows starts a Run of
its own so it still gets the space it wants.
The `{}` prefix is required: a StringFormat beginning with a brace would otherwise be parsed
as a markup extension.
-->
<TextBlock Classes="body" Margin="0,6,0,0">
<Run Text="First contact with" />
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
<Run Text="·" />
<Run Text="{Binding PendingHostKey.Port}" />
<Run Text=". Nothing in this keychain has approved this key." />
<Run Text="{Binding PendingHostKey.Port, StringFormat='{}{0}.'}" />
<Run Text="Nothing in this keychain has approved this key." />
</TextBlock>
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
@@ -465,7 +465,7 @@
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
TextTrimming="CharacterEllipsis" />
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
</StackPanel>
@@ -1029,6 +1029,48 @@
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
moment one is wanted is while deciding how a host authenticates, and this head has no keychain
editor for credentials at all — so without this a phone could bind a host to a credential but
never make one. Writes to the keychain the instant ADD is pressed, exactly as the new-tag box
below does and for the same reason: a host can only name an id that exists.
-->
<Button Classes="secondary" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
MinHeight="40" Padding="14,0"
IsVisible="{Binding !IsAddingEditorCredential}"
Command="{Binding BeginEditorCredentialCommand}" />
<Border CornerRadius="12" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
IsVisible="{Binding IsAddingEditorCredential}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="NEW CREDENTIAL" />
<TextBox Classes="field" Text="{Binding EditorNewCredentialLabel}"
PlaceholderText="name" />
<!--
Optional, and what makes a credential its own item: one account on twenty machines is
rotated in one place. Left blank, this host's own username is used.
-->
<TextBox Classes="field" Text="{Binding EditorNewCredentialUsername}"
PlaceholderText="username (blank: this host's own)" />
<TextBox Classes="field secret" Text="{Binding EditorNewCredentialPassword}"
PlaceholderText="password" />
<TextBox Classes="field" Text="{Binding EditorNewCredentialNotes}"
PlaceholderText="notes" />
<TextBlock Classes="detail" TextWrapping="Wrap"
Text="Added to the keychain as soon as you press ADD, so it stays even if you leave this host without saving." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Content="ADD" MinHeight="44"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding AddEditorCredentialCommand}" />
<Button Grid.Column="2" Classes="secondary" Content="CANCEL" MinHeight="44"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding CancelEditorCredentialCommand}" />
</Grid>
</StackPanel>
</Border>
<!--
◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than
one vault that can be written to, exactly as on the desktop — an existing host's vault is not a
@@ -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}"
@@ -320,8 +320,14 @@
which was true when a tab could not exist without a session; one can now —
connecting opens the tab first — and a dot that was green before anything had
answered would be the one thing on this strip claiming something untrue.
Amber while it is being made, which is the other half of that correction. Not being
green stopped the dot lying, but it left a tab still dialling drawn exactly like a
tab whose shell has exited — the two states on this strip with the least in common,
one worth waiting for and one over. See Phone.axaml.
-->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
</StackPanel>
@@ -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();
}
}
@@ -33,9 +33,94 @@
gesture does the same thing the arrow does.
-->
<UserControl.Styles>
<!--
── the connecting step list ─────────────────────────────────────────────────────────────────────
The same five rows the desktop's ConnectingCard draws, from the same reported phases, in this head's
own sizes. Kept here rather than in Phone.axaml because nothing else on this head has a step list —
the theme file is for what more than one screen shares, and a rule that exists for one control is
easier to read beside it.
Amber for the step in flight, green behind it, red where it stopped. That is the palette's rule
rather than an exception to it: green is what is true and purple is what you can press, and a step
still happening is neither. See ConnectingCard.axaml for the longer version of this argument, and
Palette.axaml for the rule itself.
A phone needs this more than a desktop does, which is the same thing the connecting block below
already says about itself: mobile links are slower and drop more often, so the stretch this describes
is longer here and more likely to end badly.
-->
<!--
Its own FontFamily rather than the row also carrying the mono class, which is this head's convention
and not a stylistic preference: Phone.axaml's mono sets a colour and a size along with the family, so
a caption wearing both classes would be asking two rules for one Foreground and settling it on style
ordering. Every other text class here — body, label, title, detail — names its own family for exactly
that reason. The desktop's mono sets the family alone, which is why ConnectingCard composes the two
and this does not.
-->
<Style Selector="TextBlock.stepcaption">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.stepcaption.done">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="TextBlock.stepcaption.running">
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="TextBlock.stepcaption.stopped">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Style>
<!-- Fixed width and centred: four different characters on a ragged edge is a list that looks broken. -->
<Style Selector="TextBlock.stepmark">
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Width" Value="13" />
<Setter Property="TextAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.stepmark.done">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="TextBlock.stepmark.running">
<Setter Property="Foreground" Value="{StaticResource Warn}" />
</Style>
<Style Selector="TextBlock.stepmark.stopped">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
<!--
4 rather than the desktop's 5, which is the only deliberate difference between the two heads here:
this bar sits in a column 24 from each edge of a 360dp screen rather than under a 460-wide card, so
the same height reads as a heavier rule across a narrower span.
-->
<Style Selector="ProgressBar.steptrack">
<Setter Property="Height" Value="4" />
<Setter Property="MinHeight" Value="4" />
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Background" Value="{StaticResource Chip}" />
<Setter Property="Foreground" Value="{StaticResource Warn}" />
</Style>
<Style Selector="ProgressBar.steptrack.stopped">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
</UserControl.Styles>
<Panel>
<Grid RowDefinitions="Auto,*,Auto">
<!--
Four rows since the disconnect strip arrived. It is Auto and empty except while the selected tab's shell
has stopped, so an ordinary session pays nothing for it — and when it is up it takes its height off the
renderer rather than drawing over it, which on this head is not optional either: the renderer is a native
child view composited above everything Avalonia draws, so a banner over its rectangle would be invisible.
-->
<Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- ============ the bar ============ -->
<!--
@@ -97,8 +182,14 @@
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
<!--
Green only while there is a shell behind it, amber while one is being made, and
red where a connection broke rather than a shell exiting; see the same dot in
PhoneShell, and Phone.axaml for why neither of the last two is a rule broken.
-->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
Classes.connecting="{Binding IsConnecting}"
Classes.dropped="{Binding IsDropped}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
Text="{Binding Label}" />
@@ -250,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}" />
@@ -284,11 +375,82 @@
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
</Button>
<!--
Where a single unchanging "connecting…" used to be. The track counts steps that really finished
against the five there are — StepsDone over StepCount, never a percentage, because the arithmetic
that makes a percentage is the arithmetic that starts inventing one. See TerminalTabViewModel.
Drawn for both states rather than once per state: a refused connection has the same five rows and
the same track, and the only differences are that one row is red and the track stops where it got
to. Two templates kept identical for the sake of a colour is how the two drift apart.
-->
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="6" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
<StackPanel Orientation="Horizontal" Spacing="9">
<TextBlock Classes="stepmark"
Classes.done="{Binding IsDone}"
Classes.running="{Binding IsRunning}"
Classes.stopped="{Binding IsStopped}"
Text="{Binding Mark}" />
<TextBlock Classes="stepcaption"
Classes.done="{Binding IsDone}"
Classes.running="{Binding IsRunning}"
Classes.stopped="{Binding IsStopped}"
Text="{Binding Caption}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Only for a refusal now. While a connection is being made this used to be the whole of what this
screen said, and it is now the step list's running row said twice — so it is shown for the one
state the list cannot put into words: why it stopped.
-->
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}"
Foreground="{StaticResource Danger}"
IsVisible="{Binding SelectedTab.IsFailed}" />
<!--
Two 44-high targets side by side rather than one, and the second is the logs: the step list is
this attempt and the log is every other one, which is the question a connection that is taking too
long on a mobile link actually raises — has this machine ever worked from here. Reached the
ordinary way, through ShowScreenCommand, exactly as the rail and MORE reach it.
-->
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Left">
<!--
Only for a refusal, and it runs the same command the disconnect strip below the renderer does —
the vault's own connect path, back into this same tab. Worth more on a phone than on a desk: the
commonest refusal here is a mobile link that was not up yet, and the alternative is going back
to the hosts screen to find a machine whose name is already on this card.
-->
<Button Classes="row" MinHeight="44" Padding="14,0"
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
IsVisible="{Binding SelectedTab.IsFailed}">
<TextBlock Classes="label" FontSize="9" Text="TRY AGAIN" />
</Button>
<Button Classes="row" MinHeight="44" Padding="14,0"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
</Button>
<Button Classes="row" MinHeight="44" Padding="14,0"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}">
<TextBlock Classes="label" FontSize="9" Text="SHOW LOGS" />
</Button>
</StackPanel>
</StackPanel>
<!--
@@ -309,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 ============ -->
@@ -320,70 +505,164 @@
-->
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
<!--
33 tall rather than 50, a third off it like the bar at the top, with the keys inside coming down from
38 to 30 for the reason the pills did: a row that shrank around its own contents would clip them.
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
The five pixels above it are a gap and not a border. The renderer is a native child view, so nothing
Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its own —
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
that has gone wrong rather than as two that are different things.
What this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
and it was wrong in two ways that compounded each other.
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
to be the first thing anybody noticed about this row.
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
which is a trade worth making right up until the control stops working, and 30 is past that.
── what the other clients do, because all of them agree ─────────────────────────────────────────────
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
second row — Termux ships two by default. That is not convention for its own sake: a key whose
position is fixed can be hit without looking, which is the entire point of a modifier row you use
while reading the output above it. A key that might have scrolled has to be found first.
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
ones that benefit, because the arrows were the keys living past the right edge.
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
Ctrl is not the better surface.
── the split ────────────────────────────────────────────────────────────────────────────────────────
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of
its own — and a terminal whose last line of output is flush against a row of grey keys reads as one
surface that has gone wrong rather than as two that are different things.
-->
<Border Grid.Row="2" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
<Grid ColumnDefinitions="*,Auto">
<!-- ============ what a terminal says once its shell has stopped ============ -->
<!--
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
its own, the screen goes off, and the tab that went quiet is discovered later with no idea whether the
shell was exited or cut off. So it names which, prints the sentence that came with it, and offers the
one useful answer.
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
VerticalAlignment="Center" />
</ScrollViewer>
Two lines rather than the desktop's one, because 360dp cannot hold a word, a sentence and two buttons
across. The word and the buttons share the top line — the buttons are where a thumb already is — and
the sentence wraps underneath at two lines maximum, which is enough for anything sshd or a socket
error actually says and short of a strip that eats the terminal.
RECONNECT is disabled rather than hidden when there is nothing to dial: a machine typed in by hand has
no keychain entry to redial, and a control visibly refusing answers "why not" better than an absent one.
See MainWindowViewModel.CanReconnectSelectedTab.
-->
<Border Grid.Row="2" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="16,8"
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Classes="dot" Classes.dropped="{Binding IsSessionDropped}" Width="7" Height="7"
VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="9" Text="{Binding SessionEndedText}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Classes="row" MinHeight="40" Padding="12,0"
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}">
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
</Button>
<Button Classes="row" MinHeight="40" Padding="12,0"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
</Button>
</StackPanel>
</Grid>
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
Text="{Binding SessionEndedDetail}"
IsVisible="{Binding SessionEndedDetail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</Border>
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
<StackPanel Spacing="4">
<!--
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
beside them.
They used to have a row of their own above the terminal, on the argument that the accessory row
scrolls and these two must never be off-screen — a terminal too small to read is exactly the state
in which hunting for the control that fixes it is worst. That argument is answered rather than
abandoned: outside the ScrollViewer they cannot scroll away, and the row they had costs 36 pixels
on a surface this change exists to give back.
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
has no keyboard to press it with, which is why the two heads differ here and nowhere else in this
screen.
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
nothing reads as the terminal having stopped responding, which is the one thing this screen must
never look like.
◆ Focusable="False", for the reason the accessory keys carry in code — see
TerminalScreen.axaml.cs. Every button on this row sits over a terminal somebody is typing into,
and an ordinary one takes Avalonia's focus off the NativeWebView on tap, which makes the package
resign the page's focus. Making the text smaller must not cost the keyboard.
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
between them instead of taking what their labels happen to need — "ctrl" is four characters and
"|" is one, and on a row of keys those two want the same target, not proportional ones.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
VerticalAlignment="Center">
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
VerticalAlignment="Center" />
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" Focusable="False"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A−" />
</Button>
<!--
The cursor keys, and the two text-size buttons riding at the end.
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" Focusable="False"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
</StackPanel>
</Grid>
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
them every time the row's contents changed.
-->
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
<!--
The text-size buttons.
They used to have a row of their own above the terminal, on the argument that the accessory row
scrolls and these two must never be off-screen — a terminal too small to read is exactly the
state in which hunting for the control that fixes it is worst. Nothing on this keypad scrolls
any more, so that argument is now satisfied by construction rather than by placement.
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
has no keyboard to press it with, which is why the two heads differ here and nowhere else in
this screen.
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
nothing reads as the terminal having stopped responding, which is the one thing this screen must
never look like.
◆ Focusable="False", for the reason the accessory keys carry in code — see
TerminalScreen.axaml.cs. Every button on this keypad sits over a terminal somebody is typing
into, and an ordinary one takes Avalonia's focus off the input surface on tap. Making the text
smaller must not cost the keyboard.
-->
<!-- No Spacing: Button.hotkey carries a 2-pixel side margin, so the gaps here match the
UniformGrid cells' without being counted twice. -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="8,0,0,0">
<Border Width="1" Height="20" Background="{StaticResource Border}" Margin="0,0,4,0"
VerticalAlignment="Center" />
<Button Classes="hotkey" Width="44" Focusable="False"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A−" />
</Button>
<Button Classes="hotkey" Width="44" Focusable="False"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</Grid>
@@ -1,12 +1,15 @@
using System.ComponentModel;
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Threading;
using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Shell.ViewModels;
@@ -37,7 +40,14 @@ internal sealed partial class TerminalScreen : UserControl
/// keys to fit 360dp produces ten targets too small to hit.
/// </para>
/// </remarks>
private static readonly (string Label, byte[] Bytes, bool Latches)[] Keys =
/// <summary>The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.</summary>
/// <remarks>
/// Seven, which is what the row divides between — see the keypad's remark in TerminalScreen.axaml for
/// why the set is fixed and flexed rather than scrollable. Adding an eighth is a matter of adding it
/// here and changing the <c>Columns</c> on the UniformGrid; the two numbers have to agree, and the row
/// simply gets narrower keys.
/// </remarks>
private static readonly (string Label, byte[] Bytes, bool Latches)[] ModifierKeys =
[
("esc", [0x1B], false),
("tab", [0x09], false),
@@ -46,12 +56,31 @@ internal sealed partial class TerminalScreen : UserControl
("-", [(byte)'-'], false),
("~", [(byte)'~'], false),
("/", [(byte)'/'], false),
];
// The cursor keys, as the ANSI sequences a PTY expects. Written out rather than composed, because
// the difference between CSI A and SS3 A is the difference between working in bash and not.
("↑", [0x1B, (byte)'[', (byte)'A'], false),
("↓", [0x1B, (byte)'[', (byte)'B'], false),
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
/// <remarks>
/// <para>
/// The ANSI sequences a PTY expects, written out rather than composed, because the difference between
/// CSI A and SS3 A is the difference between working in bash and not.
/// </para>
/// <para>
/// ◆ <b>Left, down, up, right</b> — the order every other client on the platform puts them in, and not
/// the order they were in here. On one flat row the pairs have to read as pairs: horizontal at the ends
/// and vertical in the middle means the two keys that undo each other are always neighbours, and a
/// thumb learns the row's shape rather than its labels. Up-down-left-right is the order they are named
/// in, which is a different thing from the order they are used in.
/// </para>
/// <para>
/// These four were the keys that used to live past the right-hand edge of the scroller — the most
/// pressed keys on the keypad, and the ones you had to go looking for.
/// </para>
/// </remarks>
private static readonly (string Label, byte[] Bytes, bool Latches)[] CursorKeys =
[
("←", [0x1B, (byte)'[', (byte)'D'], false),
("↓", [0x1B, (byte)'[', (byte)'B'], false),
("↑", [0x1B, (byte)'[', (byte)'A'], false),
("→", [0x1B, (byte)'[', (byte)'C'], false),
];
@@ -67,26 +96,119 @@ internal sealed partial class TerminalScreen : UserControl
private Button? controlKey;
private readonly TerminalInputSurface input;
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
private MainWindowViewModel? subscribed;
/// <summary>
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
/// </summary>
/// <remarks>
/// The keyboard is raised on the transition into the terminal and not on every notification of it.
/// <c>IsTerminalShowing</c> is a computed property re-raised by several other setters, so acting on the
/// notification rather than the edge would call <c>ShowSoftInput</c> repeatedly for one arrival.
/// </remarks>
private bool terminalWasShowing;
public TerminalScreen()
{
AvaloniaXamlLoader.Load(this);
BuildAccessoryRow();
input = this.FindControl<TerminalInputSurface>("Input")!;
input.Input += (_, e) => SendAsync(e.Bytes);
var renderer = this.FindControl<NativeWebView>("Renderer")!;
renderer.EnvironmentRequested += OnRendererEnvironmentRequested;
renderer.EnvironmentRequested += (sender, e) =>
{
OnRendererEnvironmentRequested(sender, e);
// The precise moment the platform WebView is being built, which is the earliest one at which
// there is a native view to take the keyboard away from — and the one that fires again if
// Android reclaims the renderer and the page comes back. Posted rather than called, because the
// child is not in the hierarchy yet while its environment is still being asked for.
Dispatcher.UIThread.Post(AdoptRenderer, DispatcherPriority.Loaded);
};
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
DataContextChanged += (_, _) =>
{
if (subscribed is { } previous)
{
previous.PropertyChanged -= OnShellPropertyChanged;
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
subscribed = null;
}
if (DataContext is MainWindowViewModel shell)
{
renderer.Source = shell.TerminalPageUrl;
shell.PropertyChanged += OnShellPropertyChanged;
// The snippets screen's "TYPE INTO {tab}" lands here as well as on the desktop. It types at
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
// they look. See MainWindowViewModel.TerminalFocusRequested.
shell.TerminalFocusRequested += OnTerminalFocusRequested;
subscribed = shell;
terminalWasShowing = shell.IsTerminalShowing;
}
};
}
/// <summary>
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
/// </summary>
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is not nameof(MainWindowViewModel.IsTerminalShowing) ||
sender is not MainWindowViewModel shell)
{
return;
}
var showing = shell.IsTerminalShowing;
if (showing == terminalWasShowing)
{
return;
}
terminalWasShowing = showing;
if (!showing)
{
return;
}
// Posted to Loaded so the renderer's own native child, which becomes visible on this same change,
// exists by the time AdoptRenderer goes looking for it. Idempotent with the EnvironmentRequested
// path above: whichever runs first configures the view and the other finds it already done.
Dispatcher.UIThread.Post(
() =>
{
AdoptRenderer();
input.Acquire();
},
DispatcherPriority.Loaded);
}
private void OnTerminalFocusRequested(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(input.Acquire, DispatcherPriority.Loaded);
/// <summary>
/// Takes the keyboard off the renderer and puts the taps on it to use.
/// </summary>
/// <remarks>
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
/// the tap has to be reported rather than left to the page.
/// </remarks>
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
/// <summary>
/// Puts the keyboard away on the way to the connect menu.
/// </summary>
@@ -137,18 +259,26 @@ internal sealed partial class TerminalScreen : UserControl
private void BuildAccessoryRow()
{
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
// ── nothing to hand back any more ──────────────────────────────────────────────────────────────
// This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
// the platform requests Android's own focus for Avalonia's view after dispatching each handled
// touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
// TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
// the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
// stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
// length of the press; and the Return is posted past the current dispatch, so its ordering against
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
}
foreach (var (label, bytes, latches) in Keys)
/// <summary>Puts one table's worth of keys into one row of the keypad.</summary>
/// <remarks>
/// The rows differ in what they hold and in nothing else — same control, same class, same wiring — so
/// they are filled by one method rather than by two that would drift.
/// </remarks>
private void Fill(UniformGrid row, (string Label, byte[] Bytes, bool Latches)[] keys)
{
foreach (var (label, bytes, latches) in keys)
{
var key = CreateKey(label);
@@ -171,49 +301,31 @@ internal sealed partial class TerminalScreen : UserControl
private static Button CreateKey(string label) =>
new()
{
Content = new TextBlock
{
Text = label,
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
Content = label,
// 44 wide, and that is the number that matters: the design draws these flexed across the
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
// costs nothing a width does: the keys are a single row with the terminal above and the
// system's gesture bar below, so there is no neighbour a short press can land on instead.
MinWidth = 44,
Height = 30,
Padding = new Thickness(10, 0),
CornerRadius = new CornerRadius(9),
Background = Palette("Panel"),
BorderBrush = Palette("BorderMid"),
BorderThickness = new Thickness(1),
Foreground = Palette("TextDim"),
HorizontalContentAlignment = HorizontalAlignment.Center,
// ◆ Every dimension and brush this used to set by hand is now Button.hotkey in Phone.axaml,
// and the move is what lets the row have a pressed state at all: a style can say :pressed and
// an assignment cannot. It also takes this file out of the business of naming the palette's
// brushes, which is how a keypad ends up with a grey nothing else uses.
//
// Content is the string rather than a TextBlock built here for the same reason — the class
// carries the font and the alignment, so a wrapper element would only be somewhere for them to
// disagree.
Classes = { "hotkey" },
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
//
// These keys are an extension of the keyboard, not a place the keyboard should go. As
// ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView
// — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing
// Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next
// thing typed on a hardware keyboard went nowhere, and the row went on working because its
// buttons are pressed rather than typed into, which is what makes it look like the terminal
// had died instead.
// ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
// terminal's input target is — and a modifier row that dismisses the keyboard every time it
// is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
// the focused element never changes: TerminalInputSurface is still it, so the input
// connection is never torn down and nothing has to be handed back.
//
// Focusable=false is what a toolbar button is, and it means the focused element never
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
//
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
// decides about its element — taking the keyboard's input connection off the terminal and
// swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
// that half back; see TerminalFocus for the whole story, including why the handing back has
// to be posted rather than done inline.
// This used to be only half a fix, because the target was the NativeWebView and Android keeps
// a focus of its own that the platform hands to Avalonia's view after every touch it
// dispatches. That half is gone rather than countered: under ADR 0015 the input target is an
// Avalonia control, so the focus Android moves is already where the keyboard should be.
Focusable = false,
};
@@ -228,18 +340,12 @@ internal sealed partial class TerminalScreen : UserControl
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
// ^L to a database prompt believing they typed an l.
controlKey.Background = Palette(controlLatched ? "Active" : "Panel");
controlKey.Foreground = Palette(controlLatched ? "AccentText" : "TextDim");
//
// A class rather than two brush assignments, so what "latched" looks like is stated once in
// Phone.axaml beside what "pressed" looks like — which is where somebody changing either will be.
controlKey.Classes.Set("latched", controlLatched);
}
/// <summary>One brush from <c>Theme/Palette.axaml</c>, by key.</summary>
/// <remarks>
/// The accessory row is built in code because its keys come from a table, so its colours cannot be set in
/// XAML with the rest of the screen's. Resolving them by name is the next best thing: a row that named
/// its own blues is how the palette ends up with a fifth surface nobody meant to add.
/// </remarks>
private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush;
/// <remarks>
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
/// 0x1A, which is letter &amp; 0x1F. Applied only to letters, because Ctrl with an arrow key is a
@@ -220,7 +220,7 @@
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
<!--
◆ The one fact on this row that decides whether the button at the foot of the screen
+11 -11
View File
@@ -74,9 +74,9 @@
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
"requested": "[10.0.11, )",
"resolved": "10.0.11",
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
},
"MinVer": {
"type": "Direct",
@@ -984,7 +984,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.storage": {
@@ -1044,9 +1044,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
@@ -1140,11 +1140,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
+138 -14
View File
@@ -475,11 +475,34 @@
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The user popover itself: the panel the rail's chip opens, in this window's own idiom rather than the
theme's. The shared MenuFlyoutPresenter rule further down this file gives every popup Chrome and a
4-pixel radius, which is right for a context menu and wrong for this one — the design draws the account
menu as a rounded card, the same radius-12 shape as every other floating surface here. Reached with
FlyoutPresenterClasses from NavRail.axaml rather than by widening that rule, so a right-click menu two
screens away does not quietly become a card as well.
-->
<Style Selector="FlyoutPresenter.poppanel">
<Setter Property="Background" Value="{StaticResource Raised}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="8" />
</Style>
<!--
A row inside the user popover: a vault switch, "New vault", Settings, Preferences, Vaults, Logout. All
six share one shape — flat, a track fill under the pointer, 8 pixels of rounding — because the popover
draws them as one list and a row that looked different from its neighbours would read as a separator
that is not one.
◆ FLAT MEANS SAYING SO, which this rule did not. It set a corner radius and a padding and left the
Background alone, so every row wore the Fluent theme's own button fill and its border: six raised pills
stacked in a menu, where the design draws six lines of text that light up under the pointer. The hover
rule below was already right and was simply invisible against a fill that was there all along. Set on
the ContentPresenter as well as on the Button, the same as Button.flat does and for the same reason —
the theme's template binds its own brush there, and a Background set only on the control loses to it.
-->
<Style Selector="Button.poprow">
<Setter Property="HorizontalAlignment" Value="Stretch" />
@@ -488,14 +511,20 @@
<Setter Property="Padding" Value="11,4" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="MinHeight" Value="20" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.poprow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="Button.poprow:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The check square beside a shown vault in the popover — magenta rather than the accent, because the
@@ -594,10 +623,13 @@
</Style>
<!--
── v5b: THE HOST HEADER'S "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ───────────────────────────────
── v5b: THE "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ─────────────────────────────────────────────
A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c>
rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it
no filled hover, only the border turning to the accent. See <c>SessionHeader.axaml</c>.
no filled hover, only the border turning to the accent.
Named for the host header it was drawn for, which v5c-4 retired; the button itself moved intact to the
head of the session sidebar and is stretched across that column there. See <c>SessionSidebar.axaml</c>.
-->
<Style Selector="Button.headerghost">
<Setter Property="Height" Value="32" />
@@ -643,6 +675,30 @@
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
── v5c-4: THE SIDEBAR'S OWN CLOSE AND REOPEN ────────────────────────────────────────────────────────
One class for both, because they are one control in two states — a 26-pixel square carrying a chevron,
at the head of the column when it is open and at the head of the rail when it is not. Square rather
than the 33-tall rows below it: it is chrome belonging to the panel, not an entry in the list the panel
is holding, and matching the rows' shape would have offered it as one.
-->
<Style Selector="Button.sidebargrip">
<Setter Property="Width" Value="26" />
<Setter Property="Height" Value="26" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.sidebargrip /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.sidebargrip:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The "+ Pin folder" / "+ Add Snip" row at the foot of each section: 30 tall rather than 33, and its own
quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections.
@@ -772,6 +828,41 @@
<Setter Property="BorderThickness" Value="1.5" />
</Style>
<!--
◆ THE FILE PANE'S PATH BOX, which used to be a TextBlock.
The SFTP pane's header printed where it was and gave no way to say where to go: reaching a directory
the breadcrumb could not see meant walking to a common ancestor and back down, one round trip a level,
and a path somebody already knew could not be typed at all. It is a real TextBox now, and this style
is the whole of what keeps it looking like the label it replaced — no fill and no border until it is
pointed at, the same mono weight and colour the TextBlock carried, and the same height, so a header
that gained an input did not gain a control that shouts about being one.
Declared after the base TextBox rules and after .address for the reason this file keeps restating:
Avalonia has no specificity, so an exception written before the rules it overrides does nothing.
-->
<Style Selector="TextBox.pathbox">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="13.5" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="MinHeight" Value="26" />
<Setter Property="Padding" Value="6,2" />
<Setter Property="CornerRadius" Value="6" />
</Style>
<Style Selector="TextBox.pathbox /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="TextBox.pathbox:pointerover /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="{StaticResource Field}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
</Style>
<Style Selector="TextBox.pathbox:focus /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="{StaticResource Field}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
</Style>
<!--
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
@@ -857,18 +948,30 @@
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
rather than narrower.
◆ 224 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
column at the window's minimum is 1016 less the rail's 190 and the drawer's 320, which is 506. The
scrolling stack inside it takes 16 of margin on each side, and the vertical scrollbar takes its own —
call the usable width 474. A WrapPanel fits floor(474 / (Width + 10)) per row, so two columns needs
Width no more than 227.
◆ 214 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
column at the window's minimum is 1081 less the rail's 255 and the drawer's 320, which is 506. The
scrolling stack inside it takes 26 of margin on each side, and the vertical scrollbar takes its own —
call the usable width 454. A WrapPanel fits floor(454 / (Width + 10)) per row, so two columns needs
Width no more than 217, and 214 is that with the same few pixels of slack the previous number kept.
The first number here was 248, from the same reasoning with the two margins left out. It laid out
cleanly and the layout harness passed it, because the harness asks whether a control is inside the
window and not how many of them fit on a line — so the grid quietly became one column wide at exactly
the size this application guarantees, which is the shape the cards exist to avoid. The second was 232,
derived the same way against the drawer's own 304; v5 widened the drawer to 320 for the ADDRESS field's
breathing room, which narrowed the budget this number is drawn from and had to move it down in step.
Every number here has moved at least once, and always because something beside the cards did:
· 248, from this reasoning with the two margins left out. It laid out cleanly and the layout harness
passed it, because the harness asks whether a control is inside the window and not how many of them
fit on a line — so the grid quietly became one column wide at exactly the size this application
guarantees, which is the shape the cards exist to avoid.
· 232, derived against the drawer's own 304, which v5 widened to 320 for the ADDRESS field's breathing
room — narrowing the budget this number is drawn from and moving it down in step.
· 224, which is what that gave. The stated arithmetic still said 1016 and 190 by then: v5b's rail took
190 to 255 and the window's minimum 1016 to 1081 in the same pass, so the two changes cancelled and
the answer stayed right while the working went stale.
· 214, now that HostsScreen's board is inset 26 a side rather than 16 — see that file's own remark on
why every screen frames its content the same way. Twenty pixels of board is twenty pixels the cards
no longer have, and this is where they come from.
◆ THE TEST THAT CATCHES THIS IS NOT THE HARNESS. See
ScreenLayoutTests.TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen, which counts columns
because that is the thing this number exists to buy and the thing no fit assertion can see.
-->
<Style Selector="Border.tile">
<Setter Property="Background" Value="{StaticResource Raised}" />
@@ -876,7 +979,7 @@
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="12,10" />
<Setter Property="Width" Value="224" />
<Setter Property="Width" Value="214" />
<Setter Property="Margin" Value="0,0,10,10" />
</Style>
<Style Selector="ListBoxItem:pointerover Border.tile">
@@ -1169,6 +1272,27 @@
<Setter Property="Fill" Value="{StaticResource Live}" />
</Style>
<!--
Amber, and it does not contradict the rule above it. Green is what is true and this is not yet true;
purple is what you can press and a dot is not pressable. What is left is the caveat colour, which is
exactly what a connection still being made is. The same amber the connecting card's track uses, from
the same brush, so that the tab and the card the tab opens agree.
-->
<Style Selector="Ellipse.dot.connecting">
<Setter Property="Fill" Value="{StaticResource Warn}" />
</Style>
<!--
Red, and it is the fourth state this dot has rather than a recolouring of the grey one. Grey is a
shell that ended — somebody typed `exit`, and nothing is wrong with the machine. This is a connection
that went out from under a session that had not finished, which is the one worth looking at: the two
used to be the same dot, so a tab that had been cut off and a tab somebody had closed down properly
were indistinguishable in the strip. See TerminalTabViewModel.EndedSummary.
-->
<Style Selector="Ellipse.dot.dropped">
<Setter Property="Fill" Value="{StaticResource Danger}" />
</Style>
<!--
The accent strip a selected row carries, drawn by the row template rather than by the item, because the
item's presenter is the thing the theme keeps repainting.
+228 -14
View File
@@ -3,6 +3,7 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Auth;
@@ -33,12 +34,77 @@ internal sealed partial class DodoSshApp : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
Compose(desktop);
ShowSomethingWhileComposing(desktop);
}
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// Puts a window on screen first, and composes the application behind it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The splash <em>is</em> the main window for a moment, which is what makes this work at all.</b> The
/// desktop lifetime shows whatever <c>MainWindow</c> holds once this method returns and then runs the
/// message loop; a window created here and merely shown would be a window with no lifetime attached, and
/// with <c>ShutdownMode.OnLastWindowClose</c> the first close of anything would end the process. Handing
/// the property over is one assignment, and Avalonia reads it lazily.
/// </para>
/// <para>
/// <b>And composition is posted rather than called.</b> It is synchronous and takes long enough to be
/// worth a splash in the first place — which is the whole argument for this method — so running it here
/// would block the very dispatcher turn that has to paint the thing. Background priority puts it behind
/// rendering, which is what makes this a painted window rather than merely a shown one.
/// </para>
/// </remarks>
private static void ShowSomethingWhileComposing(IClassicDesktopStyleApplicationLifetime desktop)
{
var splash = new SplashWindow();
desktop.MainWindow = splash;
// Fully qualified, and it has to be: Application inherits an instance Dispatcher property from
// AvaloniaObject, member lookup beats the namespace, and a static method here cannot reach an
// instance property — so the unqualified name is a compile error rather than the type. Same below.
Avalonia.Threading.Dispatcher.UIThread.Post(
() => ComposeBehind(desktop, splash),
DispatcherPriority.Background);
}
/// <remarks>
/// <para>
/// The order at the end is not interchangeable. <c>ShutdownMode</c> is <c>OnLastWindowClose</c>, so the
/// real window is shown while the splash is still open and the splash is closed afterwards; closing the
/// only window there is would end the process instead of handing it over.
/// </para>
/// <para>
/// <b>A failure is caught rather than left to the dispatcher, and the splash is exactly why.</b> Before
/// this method existed a composition that threw took the process with it and left nothing behind. Now
/// there is something to leave behind — so an exception escaping a posted callback would abandon a window
/// saying "Starting…" over a process that had stopped, which is worse than the crash it replaced. What
/// arrives instead is the sentence and a way to close it.
/// </para>
/// </remarks>
private static void ComposeBehind(
IClassicDesktopStyleApplicationLifetime desktop,
SplashWindow splash)
{
try
{
Compose(desktop);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
splash.CouldNotStart(exception.Message);
return;
}
desktop.MainWindow?.Show();
splash.Close();
}
/// <remarks>
/// <para>
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
@@ -72,6 +138,17 @@ internal sealed partial class DodoSshApp : Application
}
};
/// <summary>Reads this machine's clipboard.</summary>
/// <remarks>
/// The other direction, looked up per call for the same reason and with the same fall-through. One
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
/// </remarks>
private static Func<Task<string?>> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) =>
async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard }
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
: null;
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
/// <remarks>
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
@@ -113,14 +190,75 @@ internal sealed partial class DodoSshApp : Application
var browser = new SystemBrowserLauncher();
// Both chosen once, here, because each is a property of the machine rather than of any session. A
// computer with a usable TPM gets the store that keeps a device key behind a Windows consent prompt;
// anything else gets one that reports itself unavailable, so unlock keeps asking for the passphrase
// Windows box with a usable TPM gets a consent prompt, a Mac with a Secure Enclave gets Touch ID, a
// Linux box whose TPM this user may open gets a PIN the TPM checks — see PinPrompt — and anything
// else gets a store that reports itself unavailable, so unlock keeps asking for the passphrase
// (ADR 0007). The update channel answers the same shape of question about how this copy was
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0013.
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths, PinPrompt(desktop));
var updates = UpdateChannels.ForThisMachine();
var viewModel = new MainWindowViewModel(
var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections,
browser, updates);
desktop.MainWindow = new MainWindow { DataContext = viewModel };
// The other half of Program's claim on this profile: a second launch was refused, and this is what
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
var others = SingleInstance.Listen(
paths,
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
// Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why
// discarding the task here is safe rather than merely convenient.
_ = viewModel.StartAsync(CancellationToken.None);
WireShutdown(desktop, viewModel, workspace, caches, others);
}
/// <summary>
/// Raises the window somebody has just asked for by launching the application again.
/// </summary>
/// <remarks>
/// Un-minimised first, because activating a minimised window leaves it minimised and the launch would
/// look ignored. <c>Activate</c> is a request rather than a guarantee — Windows refuses the foreground to
/// a process that did not receive the input, and what happens then is a flashing taskbar button, which is
/// still an answer. That is the platform's own rule about focus stealing and not a thing to work around.
/// </remarks>
private static void BringToFront(Window? window)
{
if (window is null)
{
return;
}
if (window.WindowState is WindowState.Minimized)
{
window.WindowState = WindowState.Normal;
}
window.Show();
window.Activate();
}
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
/// <remarks>
/// Split out of <see cref="Compose"/> for length rather than for reuse — it has one caller, and the
/// method it came out of is at the limit the analyzers set.
/// </remarks>
private static MainWindowViewModel BuildShell(
IClassicDesktopStyleApplicationLifetime desktop,
ClientPaths paths,
ClientCacheFactory caches,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
IDeviceKeyStore deviceKeys,
SshNetConnectionFactory connections,
SystemBrowserLauncher browser,
IUpdateChannel updates) =>
new(
paths,
caches,
workspace,
@@ -140,18 +278,88 @@ internal sealed partial class DodoSshApp : Application
.ConfigureAwait(false),
copyToClipboard: ClipboardWriter(desktop),
updates: updates);
readClipboard: ClipboardReader(desktop),
updates: updates,
openInEditor: OpenInDefaultEditorAsync,
watchFile: WatchFile);
desktop.MainWindow = new MainWindow { DataContext = viewModel };
/// <summary>
/// Hands a local file to whatever this machine opens that kind of file with.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
/// Services binding depending on which desktop this is, and watching a file is a platform object with
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
private static Task OpenInDefaultEditorAsync(string path)
{
using var opened = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
// Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why
// discarding the task here is safe rather than merely convenient.
_ = viewModel.StartAsync(CancellationToken.None);
WireShutdown(desktop, viewModel, workspace, caches);
return Task.CompletedTask;
}
/// <summary>
/// Watches one file for saves, until the handle is disposed.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The directory is watched and the name filtered, which is not the same as watching the file.</b>
/// A great many editors do not write in place: they write a temporary file beside the original and
/// rename it over the top, which to a watcher pointed at an inode is the file disappearing. Watching the
/// containing directory for that name catches the rename as a <c>Created</c>, which is what makes this
/// work with the editors people actually use.
/// </para>
/// <para>
/// <b>Every kind of change is one callback, and the shell debounces nothing.</b> One save can raise two
/// or three events; what the shell does with each is stat the host and compare, which is a round trip
/// but a small one, and the alternative — a timer here — is a delay before somebody's edit reaches the
/// server for the sake of saving it.
/// </para>
/// </remarks>
private static FileSystemWatcher WatchFile(string path, Action onChanged)
{
var watcher = new FileSystemWatcher(
Path.GetDirectoryName(path)!, Path.GetFileName(path))
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName,
};
watcher.Changed += (_, _) => onChanged();
watcher.Created += (_, _) => onChanged();
watcher.Renamed += (_, _) => onChanged();
watcher.EnableRaisingEvents = true;
return watcher;
}
/// <summary>How the Linux device key store asks for a PIN, and the only thing that ever calls it.</summary>
/// <remarks>
/// The lifetime is captured rather than the window, so this can be composed before there is one —
/// composition runs first, and <c>DevicePinWindow.AskAsync</c> answers null when there is nothing to
/// sit over. Windows and macOS never reach it: on those platforms the consent dialog is the operating
/// system's and this process is not in the conversation. See ADR 0007.
/// </remarks>
private static Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> PinPrompt(
IClassicDesktopStyleApplicationLifetime desktop) =>
(purpose, cancellationToken) =>
DevicePinWindow.AskAsync(() => desktop.MainWindow, purpose, cancellationToken);
/// <remarks>
/// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and
/// blocking the UI thread on their disposal is how an application comes to take several seconds to close —
@@ -161,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
IClassicDesktopStyleApplicationLifetime desktop,
MainWindowViewModel viewModel,
TerminalWorkspace workspace,
ClientCacheFactory caches)
ClientCacheFactory caches,
IAsyncDisposable others)
{
var shuttingDown = false;
@@ -180,6 +389,11 @@ internal sealed partial class DodoSshApp : Application
await viewModel.DisposeAsync().ConfigureAwait(true);
await workspace.DisposeAsync().ConfigureAwait(true);
// Awaited rather than dropped, so the pipe name is free before this process is. It matters for
// one caller and matters a lot there: an update relaunches this application, and a successor
// that found the name still taken would be a second instance refusing itself.
await others.DisposeAsync().ConfigureAwait(true);
caches.Dispose();
desktop.Shutdown();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

@@ -225,3 +225,37 @@ $icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
$icns.Dispose()
Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)"
# ---- dodossh-<size>.png, for the Linux desktop entry ---------------------------------------------
#
# The third platform, and the only one that wants the raster loose rather than in a container. A
# freedesktop icon theme is a directory tree — hicolor/48x48/apps/dodossh.png and friends — and the
# RPM built by scripts/ci-linux-rpm.sh installs one file per size into it.
#
# Written here for the reason this whole file exists: one geometry, regenerated in one command. The
# alternative was to have the packaging script pull the 256px frame out of dodossh.ico at build time,
# which works — the .ico's entries really are PNGs — and puts a second place that has to know how an
# ICO directory is laid out. It also silently ties the Linux icon to whatever sizes Windows happens
# to want.
#
# ◆ THE TILE BLEEDS TO THE EDGE HERE, AS IT DOES ON WINDOWS.
#
# tileFraction stays 1.0. The 0.8047 inset above is Apple's icon grid and nothing else — GNOME, KDE
# and every panel that reads hicolor draw the icon at the size they asked for, with no shadow and no
# magnification to leave air for. An inset icon would simply be the small one in the launcher.
#
# ◆ AND THE SIZES ARE NOT A ROUND NUMBER OF THEM.
#
# 48 is the size a GNOME dash and most panels ask for, 128 and 256 are what a large-icon file manager
# and GNOME Software use, and 64 is between them because the 48 upscaled to it is visibly soft. There
# is deliberately nothing below 48: the hairline floor in New-MarkPng keeps a 16px mark legible, but
# no Linux desktop asks this theme for one — window titlebars there draw no icon at all.
$linuxSizes = @(48, 64, 128, 256)
foreach ($size in $linuxSizes)
{
[byte[]]$png = New-MarkPng $size
$pngTarget = Join-Path $PSScriptRoot "dodossh-$size.png"
[System.IO.File]::WriteAllBytes($pngTarget, $png)
Write-Output "Wrote $pngTarget ($((Get-Item $pngTarget).Length) bytes)"
}
@@ -151,6 +151,14 @@
interface declared in DodoSSH.Client.Session.
-->
<PackageReference Include="Velopack" />
<!--
Here for the same reason and with the same boundary as Velopack above: LinuxDeviceKeyStore is a
Linux-only implementation of an interface declared in DodoSSH.Client.Session, and it lives in
Platform/ beside the Windows and macOS stores. The Android head has its own keystore and must
never see this one.
-->
<PackageReference Include="Microsoft.TSS" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,491 @@
using System.Buffers.Binary;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.Session;
using Tpm2Lib;
namespace DodoSSH.Client.App.Platform;
/// <summary>Why the store is asking for a PIN.</summary>
/// <remarks>
/// The two cases need different words in front of a person — one is choosing a PIN and the other is
/// entering one — and the store is the only thing that knows which is happening. Passing an enum rather
/// than two delegates keeps the prompt a single seam.
/// </remarks>
public enum DevicePinPurpose
{
/// <summary>A PIN is being chosen, as part of registering this machine.</summary>
Choosing,
/// <summary>A PIN is being asked for, to unlock.</summary>
Entering,
}
/// <summary>
/// Keeps the device key sealed inside the TPM, released only against a PIN the TPM itself checks.
/// </summary>
/// <remarks>
/// <para>
/// The Linux answer to <see cref="WindowsDeviceKeyStore"/> and <c>MacDeviceKeyStore</c>, and ADR 0007's
/// bar is the one it has to clear: <b>the enforcement must be the platform's rather than this process's.</b>
/// A store that drew its own prompt over a file it could read anyway would be the mistake that ADR's
/// 2026-07-30 amendment exists to record — malware running as the user skips the prompt and reads the file.
/// </para>
/// <para>
/// <b>Here the TPM holds the key and the TPM checks the PIN.</b> The 32-byte device key is sealed into a
/// keyedhash object whose <c>userAuth</c> is derived from the PIN, so unsealing is a command the TPM
/// refuses without it. The blob on disk is encrypted to the TPM's storage hierarchy and is useless on any
/// other machine, and the PIN is never compared by anything in this assembly. Malware running as the user
/// can ask; it cannot answer.
/// </para>
/// <para>
/// <b>And the TPM counts the failures, which is what makes a short PIN safe.</b> The sealed object
/// deliberately does not carry <c>NoDA</c>, so every wrong answer advances the dictionary-attack counter
/// and the TPM stops answering at all once it trips. Measured against a software TPM: four wrong PINs and
/// it refuses the right one too, until the lockout recovers. That is the property a four-digit secret has
/// on this platform and cannot have in a file.
/// </para>
/// <para>
/// ◆ <b>What this does NOT match, stated because the difference is real and invisible.</b> Windows Hello
/// and Touch ID raise their prompt from the platform — a secure desktop on one, a system sheet on the
/// other — so the process asking never sees the secret. Linux has no equivalent to borrow, so the PIN is
/// typed into a window this application draws, and a sufficiently determined attacker already running as
/// the user could draw a convincing copy of it. The mitigation is that they could do the same for the
/// passphrase screen, which is a much larger prize, so this is not a capability the feature creates. It is
/// still a weaker guarantee than the other two desktops have and ADR 0007 says so rather than implying
/// parity.
/// </para>
/// <para>
/// <b>Availability still depends on reaching the device, and the package arranges that rather than asking
/// the user to.</b> Fedora leaves <c>/dev/tpmrm0</c> as <c>root:tss</c> mode 0660, so without help this
/// process could not open it on an ordinary desktop account. The RPM installs
/// <c>build/linux/71-dodossh-tpm.rules</c>, which tags the device <c>uaccess</c> — logind then grants an
/// ACL to whoever is logged in at the machine, and takes it away when they log out. Not over SSH and not
/// to a user on an inactive VT.
/// <para>
/// So a build run from a checkout, or one on a machine where an administrator has masked that rule, still
/// finds the device unreadable and reports unavailable — which keeps the passphrase path exactly as it was.
/// The rule carries its own reasoning, including the lockout denial-of-service it accepts; see ADR 0007
/// decision 4.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
public sealed class LinuxDeviceKeyStore : IDeviceKeyStore
{
/// <summary>What the blob on disk starts with, so a file from elsewhere is refused rather than parsed.</summary>
private static readonly byte[] Magic = "DSHTPMv1"u8.ToArray();
/// <summary>
/// Domain separation for the PIN, so the value handed to the TPM is this application's and not the
/// user's PIN as typed.
/// </summary>
/// <remarks>
/// The TPM compares an authValue of at most one digest in length, so something has to reduce a typed
/// string to bytes; hashing with a label rather than truncating means a PIN reused elsewhere does not
/// arrive at the TPM as the same bytes it does there. It buys nothing against an attacker who has the
/// blob and the machine — they have this constant too — and it costs one line.
/// </remarks>
private const string PinLabel = "dodossh/devicekey/pin/v1";
private readonly ClientPaths paths;
private readonly Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin;
private readonly Func<Tpm2Device> openDevice;
/// <summary>Creates a store against this machine's TPM.</summary>
/// <param name="paths">Where the sealed blob lives.</param>
/// <param name="askForPin">
/// Shows the PIN prompt and answers with what was typed, or null if the user declined. Declining is
/// ordinary — see <see cref="IDeviceKeyStore"/> — and lands on the passphrase.
/// </param>
public LinuxDeviceKeyStore(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
: this(paths, askForPin, static () => new LinuxTpmDevice())
{
}
/// <summary>Creates a store against a named TPM, which is how the tests reach a simulator.</summary>
/// <remarks>
/// The seam exists because this class is testable in a way the other two stores are not, and that is
/// worth keeping. Windows and macOS put the prompt inside the platform, so a test that exercised
/// sealing would block forever on a dialog — <c>WindowsDeviceKeyStoreTests</c> records the ten minutes
/// that took to discover. Here the prompt is a delegate and the TPM is a device, so both can be
/// supplied and the whole path runs unattended.
/// </remarks>
internal LinuxDeviceKeyStore(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin,
Func<Tpm2Device> openDevice)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(askForPin);
ArgumentNullException.ThrowIfNull(openDevice);
this.paths = paths;
this.askForPin = askForPin;
this.openDevice = openDevice;
}
/// <summary>
/// The parent every sealed object hangs off, as a template rather than as a stored handle.
/// </summary>
/// <remarks>
/// <para>
/// <b>Recreated from a fixed template on every use, and not made persistent.</b> A persistent handle
/// would occupy one of the TPM's small number of NV slots forever, would have to be cleaned up by
/// <see cref="ForgetAsync"/>, and would collide with whatever else on the machine had chosen the same
/// index. Deriving it costs a command and leaves nothing behind.
/// </para>
/// <para>
/// <b>ECC P-256 rather than RSA-2048, and that is a user-visible choice.</b> The two are equally
/// standard as storage roots. Creating an RSA primary on a discrete TPM takes seconds — the key is
/// generated, not derived — and this runs on the unlock path, so it would show up as the application
/// hanging every time somebody opened their vault. The prototype this was built from used RSA and that
/// is exactly what it was changed for.
/// </para>
/// <para>
/// <c>NoDA</c> is set <em>here</em> and deliberately not on the sealed object. The parent has no
/// authValue to guess, so counting failures against it would only give an attacker a way to lock the
/// machine's TPM out by asking for something they were never going to get.
/// </para>
/// </remarks>
private static TpmPublic PrimaryTemplate => new(
TpmAlgId.Sha256,
ObjectAttr.Restricted | ObjectAttr.Decrypt | ObjectAttr.FixedTPM | ObjectAttr.FixedParent
| ObjectAttr.SensitiveDataOrigin | ObjectAttr.UserWithAuth | ObjectAttr.NoDA,
null,
new EccParms(
new SymDefObject(TpmAlgId.Aes, 128, TpmAlgId.Cfb),
new NullAsymScheme(),
EccCurve.TpmEccNistP256,
new NullKdfScheme()),
new EccPoint());
/// <summary>
/// The sealed object: bytes the TPM holds, released against an authValue the TPM checks.
/// </summary>
/// <remarks>
/// Keyedhash with a null scheme is TPM 2.0's "just keep these bytes" object. <c>UserWithAuth</c> is
/// what makes the authValue the thing that opens it, and the deliberate <em>absence</em> of
/// <c>NoDA</c> is what puts every wrong PIN on the dictionary-attack counter. Removing that one
/// attribute would leave a four-digit secret that could be guessed at full speed by anything holding
/// the blob and the machine, which is the whole of the protection this class offers.
/// </remarks>
private static TpmPublic SealedTemplate => new(
TpmAlgId.Sha256,
ObjectAttr.FixedTPM | ObjectAttr.FixedParent | ObjectAttr.UserWithAuth,
null,
new KeyedhashParms(new NullSchemeKeyedhash()),
new Tpm2bDigestKeyedhash());
/// <summary>Whether this machine has a TPM this process may use.</summary>
/// <remarks>
/// Probes by creating and flushing a primary rather than by looking for the device node, for the
/// reason <c>WindowsDeviceKeyStore.IsSupported</c> gives about its own provider: the node can be
/// present on a machine where the first real command then fails. Permissions are the common case here
/// and they fail at open; an owner hierarchy with an authValue set is the rarer one and fails at
/// CreatePrimary. Neither is a fault and both mean the same thing.
/// </remarks>
internal static bool IsSupported() => IsSupported(static () => new LinuxTpmDevice());
/// <inheritdoc cref="IsSupported()" />
internal static bool IsSupported(Func<Tpm2Device> openDevice)
{
ArgumentNullException.ThrowIfNull(openDevice);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
tpm.FlushContext(primary);
return true;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Every way this fails means the same thing to the caller: no device key here, so keep asking
// for the passphrase. A missing device node throws IOException, a device this user may not
// open throws UnauthorizedAccessException, and a TPM that refuses the command throws
// TpmException — and on a machine with no TPM at all the constructor itself may throw before
// any of that.
return false;
}
}
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(IsSupported(openDevice));
/// <inheritdoc />
/// <remarks>
/// Asks for a PIN first and writes nothing if the user declines, which matters more here than the
/// symmetry suggests: <c>SaveAsync</c> is called as part of registering this machine, and a half-done
/// registration would leave a wrap on the server that nothing can open.
/// </remarks>
public async ValueTask SaveAsync(
ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken)
{
var pin = await askForPin(DevicePinPurpose.Choosing, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(pin))
{
throw new OperationCanceledException("No PIN was chosen, so no device key was stored.");
}
var key = devicePrivateKey.ToArray();
// Off the calling thread, because every command below blocks on the chip. Creating the primary
// alone is hundreds of milliseconds on real hardware, and this is called from the unlock path —
// which on the desktop head is the UI thread.
var packed = await Task.Run(() => Seal(pin, key), cancellationToken).ConfigureAwait(false);
paths.EnsureCreated();
await File.WriteAllBytesAsync(paths.DeviceKeyFile, packed, cancellationToken).ConfigureAwait(false);
}
/// <summary>Seals the key into the TPM and answers with the blob to store.</summary>
private byte[] Seal(string pin, byte[] devicePrivateKey)
{
var auth = PinAuth(pin);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
try
{
var priv = tpm.Create(
primary,
new SensitiveCreate(auth, devicePrivateKey),
SealedTemplate,
null,
[],
out var pub,
out _,
out _,
out _);
return Pack(pub, priv);
}
finally
{
tpm.FlushContext(primary);
}
}
finally
{
CryptographicOperations.ZeroMemory(auth);
CryptographicOperations.ZeroMemory(devicePrivateKey);
}
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>One attempt per call, and a wrong PIN is not retried here.</b> Every wrong answer advances the
/// TPM's lockout counter, so a loop offering three tries would spend a third of the machine's budget
/// on one mistyped digit. Returning null lands on the passphrase, which always works; somebody who
/// wants to try the PIN again asks for a device unlock again, which is a deliberate act rather than a
/// loop.
/// </para>
/// <para>
/// <b>A locked-out TPM is deliberately indistinguishable from a wrong PIN.</b>
/// <see cref="IDeviceKeyStore.TryLoadAsync"/> asks for exactly that and the reasoning holds here: the
/// caller does the same thing in every case, and "your TPM is in lockout" describes the keystore
/// rather than telling somebody something they can act on — the answer is still to type the
/// passphrase.
/// </para>
/// </remarks>
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(paths.DeviceKeyFile))
{
return null;
}
var stored = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false);
if (!TryUnpack(stored, out var pub, out var priv))
{
return null;
}
// After the blob is known to be readable and before anything is asked of the user, because a
// prompt raised over a file this cannot use is a question with no useful answer.
var pin = await askForPin(DevicePinPurpose.Entering, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(pin))
{
return null;
}
return await Task.Run(() => Unseal(pin, pub, priv), cancellationToken).ConfigureAwait(false);
}
/// <summary>Asks the TPM to release the key, and answers null however it declines.</summary>
private byte[]? Unseal(string pin, TpmPublic pub, TpmPrivate priv)
{
var auth = PinAuth(pin);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
try
{
var loaded = tpm.Load(primary, priv, pub);
try
{
return tpm[auth].Unseal(loaded);
}
finally
{
tpm.FlushContext(loaded);
}
}
finally
{
tpm.FlushContext(primary);
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// TpmException covers the wrong PIN and the lockout; the rest covers a TPM that has gone away
// between the availability check and now, which a suspend or a firmware update can do.
return null;
}
finally
{
CryptographicOperations.ZeroMemory(auth);
}
}
/// <inheritdoc />
/// <remarks>
/// Deleting the blob is the whole of it, and that is a property of sealing rather than an omission.
/// The object lives nowhere but in that file — it was never made persistent inside the TPM, see
/// <see cref="PrimaryTemplate"/> — so the bytes are unrecoverable the moment it is gone. There is no
/// TPM state to clean up and nothing left behind for a later install to trip over.
/// </remarks>
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
if (File.Exists(paths.DeviceKeyFile))
{
File.Delete(paths.DeviceKeyFile);
}
return ValueTask.CompletedTask;
}
/// <summary>Derives the TPM authValue from the PIN as typed.</summary>
private static byte[] PinAuth(string pin) =>
SHA256.HashData(Encoding.UTF8.GetBytes($"{PinLabel} {pin}"));
private static TpmHandle CreatePrimary(Tpm2 tpm) =>
tpm.CreatePrimary(
TpmRh.Owner,
new SensitiveCreate(),
PrimaryTemplate,
null,
[],
out _,
out _,
out _,
out _);
/// <summary>Puts the two halves of the sealed object in one file.</summary>
/// <remarks>
/// Both are needed to load it and neither is secret — the private half is encrypted to this TPM's
/// storage hierarchy, which is what makes the file useless anywhere else. Length-prefixed rather than
/// concatenated because their sizes vary with the TPM's algorithms, and magic-prefixed so that a file
/// left by another version, or by the Windows store on a dual-boot profile directory, is refused
/// rather than fed to the TPM as a malformed structure.
/// </remarks>
private static byte[] Pack(TpmPublic pub, TpmPrivate priv)
{
var publicPart = pub.GetTpmRepresentation();
var privatePart = priv.GetTpmRepresentation();
var packed = new byte[Magic.Length + 4 + publicPart.Length + 4 + privatePart.Length];
var at = Magic.Length;
Magic.CopyTo(packed.AsSpan());
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), publicPart.Length);
at += 4;
publicPart.CopyTo(packed.AsSpan(at));
at += publicPart.Length;
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), privatePart.Length);
at += 4;
privatePart.CopyTo(packed.AsSpan(at));
return packed;
}
/// <summary>Reads back what <see cref="Pack"/> wrote, or answers false.</summary>
private static bool TryUnpack(byte[] stored, out TpmPublic pub, out TpmPrivate priv)
{
pub = new TpmPublic();
priv = new TpmPrivate();
try
{
if (stored.Length < Magic.Length + 8 || !stored.AsSpan(0, Magic.Length).SequenceEqual(Magic))
{
return false;
}
var at = Magic.Length;
var publicLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
at += 4;
if (publicLength < 0 || at + publicLength + 4 > stored.Length)
{
return false;
}
var publicPart = stored.AsSpan(at, publicLength).ToArray();
at += publicLength;
var privateLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
at += 4;
if (privateLength < 0 || at + privateLength != stored.Length)
{
return false;
}
var privatePart = stored.AsSpan(at, privateLength).ToArray();
pub = Marshaller.FromTpmRepresentation<TpmPublic>(publicPart);
priv = Marshaller.FromTpmRepresentation<TpmPrivate>(privatePart);
return true;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// A blob that does not parse is a blob this machine cannot use, which is the same answer as no
// blob at all. Throwing here would mean a corrupted file stopped the application from starting.
return false;
}
}
}
@@ -0,0 +1,495 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>What a channel's release says it is publishing.</summary>
/// <remarks>
/// <para>
/// Written by <c>scripts/ci-linux-rpm.sh</c> and read here, and it is a small file this repository
/// controls rather than the forge's own release JSON for the reason the android manifest gives: this is
/// fetched on a six-hourly timer, and a document whose shape changes when the forge is upgraded is not a
/// contract.
/// </para>
/// <para>
/// <b>The comparison is <see cref="RpmVersion"/> and never <see cref="Version"/>.</b> That is the string
/// <c>rpm</c> will order the install against, so comparing anything else would let this offer an update
/// PackageKit then refuses — the same argument <c>AndroidChannelManifest</c> makes for <c>versionCode</c>.
/// The two differ exactly where it matters: <c>0.0.1-alpha.0.143</c> and <c>0.0.1~alpha.0.143</c> are one
/// build, and only the second sorts below <c>0.0.1</c>.
/// </para>
/// </remarks>
/// <param name="RpmVersion">The version-release string rpm knows the package by.</param>
/// <param name="Version">What that build calls itself, for a human.</param>
/// <param name="Rpm">The asset on the same release that holds it.</param>
/// <param name="Sha256">The package's digest, lower-case hex.</param>
internal sealed record LinuxChannelManifest(
[property: JsonPropertyName("rpmVersion")] string RpmVersion,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("rpm")] string Rpm,
[property: JsonPropertyName("sha256")] string Sha256);
/// <summary>One release as the forge describes it. Only the assets are read.</summary>
internal sealed record LinuxForgeRelease(
[property: JsonPropertyName("assets")] IReadOnlyList<LinuxForgeAsset>? Assets);
/// <summary>One file attached to a release.</summary>
internal sealed record LinuxForgeAsset(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("browser_download_url")] string? DownloadUrl);
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(LinuxChannelManifest))]
[JsonSerializable(typeof(LinuxForgeRelease))]
internal sealed partial class LinuxForgeJsonContext : JsonSerializerContext;
/// <summary>
/// Fetches a newer RPM from the project's own forge and asks the system's package manager to install it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here replaces anything.</b> That is the difference from
/// <see cref="VelopackUpdateChannel"/> and it is the decision ADR 0013 rule 11 records rather than an
/// unfinished half: on this platform the package manager is already an updater the user trusts, audits and
/// controls, so this class does the two things the package manager cannot — notice that a build exists and
/// fetch it — and then hands the file to PackageKit, which raises polkit and asks. The shape is the
/// phone's, not the desktop's: <see cref="ApplyingEndsTheProcess"/> is false, and the answer may be no.
/// </para>
/// <para>
/// <b>The feed is a constant and the deployment is never asked.</b> ADR 0011 rule 2, carried unchanged
/// from both other channels: an operator who could answer the update check could pin a chosen user to a
/// build with a known hole, without holding any key at all.
/// </para>
/// <para>
/// <b>The digest is checked and is not a signature.</b> Velopack verifies what it downloads and Android
/// refuses a package whose signature does not match the installed one; an RPM fetched over HTTPS and
/// handed to PackageKit has neither. So the manifest names a digest and this refuses anything else —
/// which catches a truncated download, a caching proxy and a corrupted mirror, and catches nothing at all
/// about who built the file, because the same job wrote both. Provenance here is the constant address; a
/// signed repository is what would improve on it, and ADR 0013 rule 11 says why there is not one yet.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
internal sealed class LinuxUpdateChannel : IUpdateChannel
{
/// <summary>The project's own forge, and the one address in this file.</summary>
/// <remarks>
/// The owner half of the path matters for the reason <c>AndroidUpdateChannel</c> records: the
/// repository moved between organisations and Gitea leaves a 301 behind, so a client naming the old
/// one works until somebody deletes the redirect.
/// </remarks>
private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH";
private readonly string channel;
private readonly string packageName;
private readonly string dataDirectory;
private readonly HttpClient http;
private readonly Func<string, string?> installedVersion;
private readonly Action<string> handToInstaller;
private (string Version, string RpmVersion, string Url, string Sha256)? found;
private string? fetched;
/// <summary>Creates a channel for the running build.</summary>
internal LinuxUpdateChannel(string channel)
: this(
channel,
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
new HttpClient(),
InstalledRpmVersion,
HandToPackageKit)
{
}
/// <summary>Creates a channel with its outside world supplied, which is how the tests reach it.</summary>
internal LinuxUpdateChannel(
string channel,
string dataDirectory,
HttpClient http,
Func<string, string?> installedVersion,
Action<string> handToInstaller)
{
ArgumentException.ThrowIfNullOrEmpty(channel);
ArgumentException.ThrowIfNullOrEmpty(dataDirectory);
ArgumentNullException.ThrowIfNull(http);
ArgumentNullException.ThrowIfNull(installedVersion);
ArgumentNullException.ThrowIfNull(handToInstaller);
this.channel = channel;
this.dataDirectory = dataDirectory;
this.http = http;
this.installedVersion = installedVersion;
this.handToInstaller = handToInstaller;
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal)
? "dodossh-nightly"
: "dodossh";
}
/// <inheritdoc />
/// <remarks>
/// True unconditionally, because whether this copy is one that can be updated was already decided by
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at
/// composition rather than at every call site.
/// </remarks>
public bool IsSupported => true;
/// <inheritdoc />
public string CurrentVersion => ClientVersion.Current;
/// <inheritdoc />
/// <remarks>
/// False, and for the phone's reason rather than the desktop's. Applying hands a file to PackageKit
/// and returns; polkit then asks, and the user may say no. A caller that had already torn the vault
/// down would answer "not now" with a locked vault and every shell gone.
/// </remarks>
public bool ApplyingEndsTheProcess => false;
/// <inheritdoc />
/// <exception cref="HttpRequestException">The forge could not be reached, or refused.</exception>
/// <exception cref="InvalidOperationException">The release is there and does not carry a usable build.</exception>
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
{
var release = await ReadAsync(
ReleaseUrl(),
LinuxForgeJsonContext.Default.LinuxForgeRelease,
cancellationToken)
.ConfigureAwait(false);
var manifestName = $"linux-{channel}.json";
if (Asset(release, manifestName) is not { } manifestAsset)
{
throw new InvalidOperationException(
$"The {channel} release carries no {manifestName}, so there is nothing saying what it "
+ "publishes.");
}
var manifest = await ReadAsync(
manifestAsset,
LinuxForgeJsonContext.Default.LinuxChannelManifest,
cancellationToken)
.ConfigureAwait(false);
if (manifest is null)
{
throw new InvalidOperationException(
$"The {channel} release's {manifestName} could not be read as a manifest.");
}
// The one place null is returned, and it means what null is documented to mean: this build is
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0)
{
return null;
}
if (Asset(release, manifest.Rpm) is not { } rpm)
{
throw new InvalidOperationException(
$"The {channel} release advertises {manifest.Version} but carries no {manifest.Rpm}.");
}
found = (manifest.Version, manifest.RpmVersion, rpm, manifest.Sha256);
return new AvailableUpdate(manifest.Version);
}
/// <inheritdoc />
public async Task DownloadAsync(
AvailableUpdate update,
IProgress<int> progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(update);
ArgumentNullException.ThrowIfNull(progress);
if (found is not { } target
|| !string.Equals(target.Version, update.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException(
"That update did not come from this channel's last check. Check again before downloading.");
}
// Under the profile directory rather than /tmp, because a package this size on a tmpfs is a
// machine that runs out of memory downloading an update. One name, overwritten, so a client that
// fetched three updates it never installed is not holding three hundred megabytes.
var directory = Path.Combine(dataDirectory, "updates");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, $"{packageName}-update.rpm");
await FetchAsync(target.Url, path, progress, cancellationToken).ConfigureAwait(false);
// ◆ Verified before it is ever named to the installer, and the file is removed if it does not
// match. Leaving a package that failed its digest on disk would put a file somebody could install
// by hand next to a filename that says it is an update.
var digest = Convert.ToHexStringLower(await Sha256OfAsync(path, cancellationToken).ConfigureAwait(false));
if (!string.Equals(digest, target.Sha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(path);
throw new InvalidOperationException(
$"The downloaded package is not the one {channel} published: expected {target.Sha256}, "
+ $"got {digest}.");
}
fetched = path;
}
/// <summary>Streams one URL to one path, reporting progress as it goes.</summary>
private async Task FetchAsync(
string url,
string path,
IProgress<int> progress,
CancellationToken cancellationToken)
{
using var response = await http
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength ?? 0;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var destination = File.Create(path);
await using (stream.ConfigureAwait(false))
await using (destination.ConfigureAwait(false))
{
var buffer = new byte[81920];
long copied = 0;
int read;
while ((read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
.ConfigureAwait(false);
copied += read;
if (total > 0)
{
progress.Report((int)(copied * 100 / total));
}
}
}
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>This installs nothing and returns immediately.</b> It asks PackageKit — the freedesktop service
/// every RPM distribution runs — to install a local file, which raises a polkit prompt drawn by the
/// desktop rather than by this application. The user may refuse, and refusing is not a failure.
/// </para>
/// <para>
/// <b>The process is not restarted and must not be.</b> Replacing files under a running process is
/// ordinary on Linux: the old inodes stay mapped and this build keeps running until somebody closes
/// it. That is the right behaviour for an application that deliberately keeps shells open across a
/// lock — the same argument ADR 0013 makes for waiting for a restart the user asks for — and it is why
/// the name inherited from the interface reads oddly here. There is no equivalent of Android's
/// "the platform ends the process", so nothing ends it.
/// </para>
/// </remarks>
public void ApplyAndRestart(AvailableUpdate update)
{
ArgumentNullException.ThrowIfNull(update);
if (fetched is not { } path || !File.Exists(path))
{
throw new InvalidOperationException("There is no downloaded update to install.");
}
handToInstaller(path);
}
/// <summary>Which release this build's channel reads.</summary>
/// <remarks>
/// <c>releases/latest</c> skips prereleases, which keeps the nightly — published as one — out of the
/// release channel's answer even though both live on the same forge.
/// </remarks>
private string ReleaseUrl() =>
string.Equals(channel, "nightly", StringComparison.Ordinal)
? $"{RepositoryApi}/releases/tags/nightly-desktop"
: $"{RepositoryApi}/releases/latest";
private static string? Asset(LinuxForgeRelease? release, string name) =>
release?.Assets?.FirstOrDefault(
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
private async Task<T?> ReadAsync<T>(
string url,
JsonTypeInfo<T> shape,
CancellationToken cancellationToken)
{
var stream = await http.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
return await JsonSerializer
.DeserializeAsync(stream, shape, cancellationToken)
.ConfigureAwait(false);
}
}
private static async Task<byte[]> Sha256OfAsync(string path, CancellationToken cancellationToken)
{
var stream = File.OpenRead(path);
await using (stream.ConfigureAwait(false))
{
return await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// What rpm records for the installed package, which is the number a newer build must beat.
/// </summary>
/// <remarks>
/// <para>
/// Read from the package database rather than from the assembly, for the reason
/// <c>AndroidUpdateChannel.ReadInstalledVersionCode</c> gives about reading it from the platform: the
/// assembly's version is a SemVer string and this comparison has to be the one the installer will
/// make. Deriving the rpm version from the assembly here would mean a second copy of the translation
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
/// </para>
/// <para>
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then
/// an install PackageKit declines rather than a security fix that never arrives.
/// </para>
/// </remarks>
private static string? InstalledRpmVersion(string packageName)
{
try
{
using var process = Process.Start(new ProcessStartInfo("rpm")
{
ArgumentList = { "-q", "--queryformat", "%{VERSION}-%{RELEASE}", packageName },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
});
if (process is null)
{
return null;
}
var answer = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(TimeSpan.FromSeconds(10));
return process.ExitCode == 0 && answer.Length > 0 ? answer : null;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
return null;
}
}
/// <summary>Asks PackageKit to install a local file, and lets polkit do the asking.</summary>
/// <remarks>
/// <para>
/// <b>Over <c>gdbus</c> rather than a D-Bus library, and that is a dependency argument rather than a
/// stylistic one.</b> <c>gdbus</c> ships in glib2, which this package already depends on twice over —
/// GTK and WebKitGTK are the terminal — so it is present on every machine that can run this at all.
/// Adding a managed D-Bus stack to reach two method calls would be a package, a lock file entry and a
/// second thing to keep current, for no capability. <c>BrowserLauncher</c> settles the same trade the
/// same way one layer down.
/// </para>
/// <para>
/// <b>Two calls, because PackageKit's interface is a factory.</b> <c>CreateTransaction</c> returns an
/// object path and the work is asked of that; a transaction is single-use, so this cannot be cached.
/// </para>
/// <para>
/// <b>The flags word is zero, and zero is load-bearing.</b> Bit 0 is <c>ONLY_TRUSTED</c>. This
/// package is not signed — ADR 0013 rule 11 says why, and why that is not decision 8's "unsigned for
/// now" — so requiring trust would make every update fail at the last step. Clearing it is what routes
/// the request to polkit's <c>package-install-untrusted</c> action, which asks for an administrator
/// rather than proceeding quietly. The user sees what they are agreeing to.
/// </para>
/// </remarks>
private static void HandToPackageKit(string path)
{
var transaction = Gdbus(
"--system",
"--dest", "org.freedesktop.PackageKit",
"--object-path", "/org/freedesktop/PackageKit",
"--method", "org.freedesktop.PackageKit.CreateTransaction");
// gdbus prints a GVariant tuple — (objectpath '/17_beadcfae',) — and the path is the only thing
// in it. Parsed by taking what is between the quotes rather than with a regular expression over a
// format nobody has promised to keep.
var opened = transaction.IndexOf('\'', StringComparison.Ordinal);
var closed = transaction.LastIndexOf('\'');
if (opened < 0 || closed <= opened)
{
throw new InvalidOperationException(
$"PackageKit did not return a transaction to install into: {transaction}");
}
var objectPath = transaction[(opened + 1)..closed];
Gdbus(
"--system",
"--dest", "org.freedesktop.PackageKit",
"--object-path", objectPath,
"--method", "org.freedesktop.PackageKit.Transaction.InstallFiles",
"0",
$"['{path}']");
}
/// <summary>Runs one gdbus call and answers with its output.</summary>
private static string Gdbus(params string[] arguments)
{
var start = new ProcessStartInfo("gdbus")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var argument in arguments)
{
start.ArgumentList.Add(argument);
}
using var process = Process.Start(start)
?? throw new InvalidOperationException(
"gdbus could not be started, so the package could not be handed to PackageKit.");
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit(TimeSpan.FromSeconds(30));
if (process.ExitCode != 0)
{
// The message is shown to the user by UpdateViewModel, so it says what to do rather than what
// failed. A missing PackageKit is the likely cause on a minimal install and is the one thing
// they can fix.
throw new InvalidOperationException(
string.Format(
CultureInfo.CurrentCulture,
"PackageKit would not take the package. Install it with `sudo dnf install PackageKit`, "
+ "or install the file yourself: {0}. ({1})",
arguments[^1],
error.Trim()));
}
return output;
}
}
@@ -0,0 +1,213 @@
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Compares two RPM version strings the way <c>rpm</c> itself does.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists rather than a SemVer comparison.</b> <c>AndroidUpdateChannel</c> settles the same
/// question and its reasoning carries over exactly: the comparison has to be the one the installer will
/// make, because anything else can offer an update the platform then refuses. On the phone that number is
/// Android's <c>versionCode</c>; here it is the RPM version, and the authority that reads it is
/// <c>rpm</c> — so this is <c>rpmvercmp</c> and not an approximation of it.
/// </para>
/// <para>
/// It matters in a specific direction. The nightly channel is rolling and CI replaces it on every push, so
/// the published version normally only rises — but a revert, a force-push or a re-run of an older commit
/// can lower it, and a client comparing "is this different from mine" would then offer a downgrade that
/// PackageKit declines. The user sees an update that fails, every six hours, forever.
/// </para>
/// <para>
/// <b>The algorithm is rpm's, including the parts that look like bugs.</b> Segments of digits and segments
/// of letters are compared separately and never against each other; everything that is neither is a
/// separator, so <c>1.0_1</c> and <c>1.0.1</c> are equal. Leading zeros are stripped, so <c>1.01</c> and
/// <c>1.1</c> are equal too. <c>~</c> sorts before anything including the end of the string, which is what
/// makes <c>0.0.1~alpha.0.143</c> older than <c>0.0.1</c> and is the whole reason
/// <c>scripts/ci-linux-rpm.sh</c> translates a SemVer prerelease to one. <c>^</c> sorts after everything
/// except the end of the string.
/// </para>
/// <para>
/// Verified against <c>rpm</c>'s own answers rather than against a reading of the source: the cases in
/// <c>RpmVersionTests</c> were generated by running <c>rpm.vercmp</c> in a container, so a disagreement is
/// a disagreement with the program that will actually decide.
/// </para>
/// </remarks>
internal static class RpmVersion
{
/// <summary>
/// Orders two RPM version strings.
/// </summary>
/// <param name="left">The left version.</param>
/// <param name="right">The right version.</param>
/// <returns>-1, 0 or 1, as <c>rpmvercmp</c> would answer.</returns>
/// <remarks>
/// Null and empty are accepted and treated as equal to each other and older than anything else, which
/// <c>rpm</c>'s own Lua binding refuses outright. The refusal is right for a packaging tool and wrong
/// here: this compares a version read off a feed, and a feed that answered with an empty string should
/// leave the client on the build it has rather than throwing on a background timer.
/// </remarks>
internal static int Compare(string? left, string? right)
{
var one = left ?? string.Empty;
var two = right ?? string.Empty;
if (string.Equals(one, two, StringComparison.Ordinal))
{
return 0;
}
int i = 0, j = 0;
while (i < one.Length || j < two.Length)
{
// Separators. Anything that is not alphanumeric, ~ or ^ divides segments and is skipped on
// both sides independently — which is why 1.0_1 and 1.0.1 compare equal.
Skip(one, ref i);
Skip(two, ref j);
if (CompareMarkers(one, i, two, j) is { } marker)
{
if (marker != 0)
{
return marker;
}
// Both sides carried the same marker, so step over it and keep going.
i++;
j++;
continue;
}
if (i >= one.Length || j >= two.Length)
{
break;
}
if (CompareSegments(one, ref i, two, ref j) is { } ordering)
{
return ordering;
}
}
// Both ran out together, so every segment matched and the only differences were separators.
if (i >= one.Length && j >= two.Length)
{
return 0;
}
// Whichever still has characters left has more version, and more version is newer.
return i < one.Length ? 1 : -1;
}
/// <summary>Advances past anything that divides segments rather than taking part in them.</summary>
private static void Skip(string value, ref int at)
{
while (at < value.Length && !IsVersionCharacter(value[at]))
{
at++;
}
}
/// <summary>
/// Orders the two sides when either is at a <c>~</c> or a <c>^</c>.
/// </summary>
/// <returns>
/// Null when neither side is at a marker, so the caller should compare segments; 0 when both are at
/// the same one, so the caller should step over it; otherwise the ordering.
/// </returns>
/// <remarks>
/// <c>~</c> sorts before everything including the end of the string, which is what carries the whole
/// prerelease ordering. <c>^</c> sorts after everything <em>except</em> the end of the string, so
/// 1.0^post is newer than 1.0 and 1.2.3 is older than 1.2.3^ — the end-of-string checks coming first
/// is the difference between the two halves of that sentence.
/// </remarks>
private static int? CompareMarkers(string one, int i, string two, int j)
{
var oneTilde = i < one.Length && one[i] == '~';
var twoTilde = j < two.Length && two[j] == '~';
if (oneTilde || twoTilde)
{
return oneTilde && twoTilde ? 0 : oneTilde ? -1 : 1;
}
var oneCaret = i < one.Length && one[i] == '^';
var twoCaret = j < two.Length && two[j] == '^';
if (!oneCaret && !twoCaret)
{
return null;
}
if (i >= one.Length)
{
return -1;
}
if (j >= two.Length)
{
return 1;
}
return oneCaret && twoCaret ? 0 : oneCaret ? -1 : 1;
}
/// <summary>
/// Takes one segment from each side and orders them, advancing past both.
/// </summary>
/// <returns>The ordering, or null if the two segments were equal and the walk should continue.</returns>
private static int? CompareSegments(string one, ref int i, string two, ref int j)
{
var numeric = char.IsAsciiDigit(one[i]);
var start1 = i;
var start2 = j;
Take(one, ref i, numeric);
Take(two, ref j, numeric);
var segment1 = one.AsSpan(start1, i - start1);
var segment2 = two.AsSpan(start2, j - start2);
// The two sides are at segments of different kinds — digits against letters. rpm calls the
// numeric one newer, always, on the reasoning that a version that has started counting is further
// along than one still spelling. segment1 is never empty: its kind was chosen from its own first
// character.
if (segment2.IsEmpty)
{
return numeric ? 1 : -1;
}
if (numeric)
{
segment1 = segment1.TrimStart('0');
segment2 = segment2.TrimStart('0');
// Longer, once the zeros are gone, is larger. Compared before the characters are, because a
// string comparison would call "9" larger than "10".
if (segment1.Length != segment2.Length)
{
return segment1.Length > segment2.Length ? 1 : -1;
}
}
var ordering = segment1.CompareTo(segment2, StringComparison.Ordinal);
return ordering == 0 ? null : ordering > 0 ? 1 : -1;
}
/// <summary>Advances over one run of digits, or one run of letters.</summary>
private static void Take(string value, ref int at, bool numeric)
{
while (at < value.Length
&& (numeric ? char.IsAsciiDigit(value[at]) : char.IsAsciiLetter(value[at])))
{
at++;
}
}
/// <summary>Whether this character takes part in the comparison rather than dividing it.</summary>
private static bool IsVersionCharacter(char value) =>
char.IsAsciiLetterOrDigit(value) || value == '~' || value == '^';
}
@@ -0,0 +1,372 @@
using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// One desktop instance per profile, and a way for a second launch to raise the first.
/// </summary>
/// <remarks>
/// <para>
/// <b>Per profile rather than per machine, which decides two things at once.</b> The lock lives in
/// <see cref="ClientPaths.InstanceLockFile"/>, so a nightly and a release build never exclude each other —
/// they are separate applications by design, ADR 0013 — and two people signed in to one machine each get
/// their own instance. A global mutex would have had to re-derive both of those from somewhere.
/// </para>
/// <para>
/// <b>Why one instance at all.</b> The cache is a SQLite file with an outbox in it, and the whole design
/// assumes one writer: <see cref="ClientPaths"/> says so about two machines sharing a profile through a
/// cloud sync client, and two processes on one machine is the same hazard with a shorter path to it. The
/// terminal's loopback listener is the visible half — the second process fails to bind, gets a port of its
/// own or refuses to start a workspace at all — and the cache is the half that would quietly corrupt.
/// </para>
/// <para>
/// <b>A file handle rather than a named mutex</b>, because the operating system releases it when the
/// process ends <em>however</em> it ends. A named mutex abandoned by a killed process is a state both
/// platforms model differently and neither models simply; an exclusive handle is closed by the kernel on
/// Windows and by the file descriptor going away on Unix, so there is no such thing as a stale lock here.
/// </para>
/// <para>
/// <b>And a named pipe for the other half, because exiting silently is not an answer.</b> Somebody who
/// double-clicks the icon and sees nothing happen has been told the application is broken. The second
/// process asks the first to come forward and then leaves, which is what every desktop application does and
/// what makes the exclusion invisible rather than mysterious.
/// </para>
/// </remarks>
internal static class SingleInstance
{
/// <summary>The one thing a second launch has to say.</summary>
/// <remarks>
/// Compared against a constant rather than parsed, for the reason <c>MainWindow</c>'s release-focus
/// message is: one message exists and treating anything else as a command would be a wider door than
/// this needs. Anything may connect to a per-user pipe — it is not a security boundary — so what arrives
/// is checked rather than trusted.
/// </remarks>
private const string ComeForward = "dodossh.come-forward";
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
/// <remarks>
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
/// not a bound on anything slow; it is how long to wait before concluding that whoever holds the lock
/// cannot be talked to.
/// </remarks>
private const int AnswerTimeoutMilliseconds = 500;
/// <summary>How long to keep trying for a lock that is held by nobody who answers.</summary>
/// <remarks>
/// <para>
/// ◆ <b>This is the window an update relaunch lands in, and without it an update would look like a
/// crash.</b> Velopack's apply ends this process and its updater then starts the new build; the two are
/// ordered — the updater waits on the old process id — but the whole sequence is one where a lock held a
/// moment too long would make the relaunched application exit on the spot, having apparently uninstalled
/// itself. The same window covers an ordinary double-double-click, where the first process holds the file
/// and has not reached its pipe yet.
/// </para>
/// <para>
/// Only reached when nobody answers the pipe. A live instance replies in milliseconds and the second
/// launch is gone before any of this matters.
/// </para>
/// </remarks>
private static readonly TimeSpan DefaultPatience = TimeSpan.FromSeconds(3);
/// <summary>How long to wait between attempts within that window.</summary>
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100);
/// <summary>
/// Claims this profile for this process, or asks whoever holds it to come forward.
/// </summary>
/// <returns>
/// A handle to hold for the life of the process, or <see langword="null"/> when another instance has it —
/// in which case that instance has already been asked to raise its window and this process should end.
/// </returns>
/// <param name="paths">The profile to claim. Its directory is created if it is not there.</param>
/// <param name="patience">
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
/// </param>
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
{
ArgumentNullException.ThrowIfNull(paths);
try
{
paths.EnsureCreated();
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// No profile directory means no lock file, and every build before this one started anyway. What
// follows will fail on its own terms and say something better than this could.
return NoClaim.Instance;
}
// Counted rather than clocked. Thread.Sleep is not a clock and reading one here would be pretending
// otherwise — what this bounds is the number of attempts, and the wall time is whatever the platform
// makes of them.
var attemptsLeft = (int)Math.Ceiling(
(patience ?? DefaultPatience).TotalMilliseconds / RetryDelay.TotalMilliseconds);
while (true)
{
if (TryTake(paths.InstanceLockFile) is { } taken)
{
return taken;
}
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
// round trip on a local pipe rather than the whole window below.
if (AskTheRunningOneToComeForward(paths))
{
return null;
}
if (attemptsLeft-- <= 0)
{
// Held by something that will not talk. Giving up rather than starting anyway, because what
// is being protected is a database with one writer, and "probably nobody" is not an answer
// worth corrupting it for.
return null;
}
Thread.Sleep(RetryDelay);
}
}
/// <summary>
/// Answers the launches that were refused, for as long as the returned handle is held.
/// </summary>
/// <remarks>
/// <paramref name="comeForward"/> is invoked on a thread pool thread, because that is where a pipe
/// accept completes. A caller that touches a window has to marshal.
/// </remarks>
/// <param name="paths">The profile this instance claimed.</param>
/// <param name="comeForward">What to do when another launch asks for the window.</param>
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(comeForward);
var listener = new Doorbell(PipeNameFor(paths), comeForward);
listener.Start();
return listener;
}
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
/// <returns>Whether anything was listening.</returns>
/// <remarks>
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
/// and proves itself, and this is the part with a protocol in it.
/// </remarks>
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
try
{
using var pipe = new NamedPipeClientStream(
serverName: ".",
PipeNameFor(paths),
PipeDirection.Out);
pipe.Connect(AnswerTimeoutMilliseconds);
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
writer.WriteLine(ComeForward);
return true;
}
catch (Exception exception)
when (exception is TimeoutException or IOException or UnauthorizedAccessException)
{
// Nothing there, or nothing willing. The caller decides what that means; here it is only ever
// "no".
return false;
}
}
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
/// <remarks>
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
/// tidier and is worse: it makes the file's existence part of the protocol, so a second process opening
/// it while the first is closing gets a handle to something on its way out. An empty file that is always
/// there and sometimes locked has one fewer state.
/// </remarks>
private static IDisposable? TryTake(string lockFile)
{
try
{
return new FileStream(
lockFile,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
FileOptions.None);
}
catch (IOException)
{
// Somebody else holds it. The one expected failure, and the whole point.
return null;
}
catch (UnauthorizedAccessException)
{
// A profile this user cannot write to, which is a broken installation rather than a second
// instance. Answered as a claim so the application starts and fails on something legible.
return NoClaim.Instance;
}
}
/// <summary>
/// The pipe both sides name, derived from the profile so that it is scoped exactly as the lock is.
/// </summary>
/// <remarks>
/// A digest of the path rather than the path itself: a pipe name may not contain a directory separator
/// on Windows, and on Unix the name becomes a file in a temporary directory whose length is bounded.
/// Eight bytes of SHA-256, which is not doing security work — it is making one string out of another
/// deterministically, and <c>string.GetHashCode</c> cannot because it is randomised per process.
/// </remarks>
private static string PipeNameFor(ClientPaths paths)
{
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(paths.DataDirectory));
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
}
/// <summary>A claim over nothing, for the cases where there was nothing to claim.</summary>
private sealed class NoClaim : IDisposable
{
internal static NoClaim Instance { get; } = new();
public void Dispose()
{
// Nothing was taken.
}
}
/// <summary>The accept loop that answers refused launches.</summary>
/// <remarks>
/// <para>
/// One server instance for the life of the doorbell, disconnected between callers rather than disposed
/// and rebuilt. The loop is what makes a third and fourth launch work as well as the second.
/// </para>
/// <para>
/// ◆ <b>THE ONE INSTANCE IS THE WHOLE CORRECTNESS OF THIS CLASS ON LINUX, AND A FRESH ONE PER CALLER
/// LOSES LAUNCHES.</b> This was written the obvious way — a <c>using</c> inside the loop, a new stream
/// per connection — which is fine on Windows and quietly wrong on Unix, where a named pipe is a Unix
/// domain socket. Disposing the server there closes the <em>listening</em> socket and unlinks its path,
/// so a second launch that connected while the first was being served has its connection dropped on the
/// floor: its <c>Connect</c> returned, its write succeeded into a buffer, and nobody ever read it. The
/// launch was told it had rung the doorbell and the window never came forward.
/// </para>
/// <para>
/// Measured rather than reasoned about, in a container, because the two platforms disagree and this
/// machine is only one of them: three rings back to back are answered three times with one instance and
/// once with a fresh one per connection. <c>Disconnect</c> ends the conversation and keeps the socket, so
/// there is never a moment with nothing listening.
/// </para>
/// <para>
/// Which makes <c>leaveOpen</c> on the reader load-bearing as well. A <see cref="StreamReader"/> disposes
/// the stream it was given, and the stream is now the server that has to survive into the next
/// iteration — so the reader that was harmless per-connection would close the doorbell after one ring.
/// </para>
/// </remarks>
private sealed class Doorbell : IAsyncDisposable
{
private readonly string name;
private readonly Action comeForward;
private readonly CancellationTokenSource lifetime = new();
private Task? loop;
internal Doorbell(string name, Action comeForward)
{
this.name = name;
this.comeForward = comeForward;
}
internal void Start() => loop = RunAsync(lifetime.Token);
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await lifetime.CancelAsync().ConfigureAwait(false);
if (loop is { } running)
{
// Awaited rather than abandoned, so the pipe name is free by the time this returns — which
// matters for exactly one caller: an update that is about to relaunch this application and
// would otherwise race its own successor for the name.
await running.ConfigureAwait(false);
}
lifetime.Dispose();
}
private async Task RunAsync(CancellationToken cancellationToken)
{
using var server = new NamedPipeServerStream(
name,
PipeDirection.In,
maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
while (!cancellationToken.IsCancellationRequested)
{
try
{
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
// leaveOpen, because this server outlives the conversation. See the remark above.
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
{
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
{
comeForward();
}
}
HangUp(server);
}
catch (OperationCanceledException)
{
return;
}
catch (IOException)
{
// A client that connected and hung up mid-sentence. Round again: giving up here would
// leave every later launch with nothing to talk to, which is the silent-exit behaviour
// this class exists to avoid.
HangUp(server);
}
}
}
/// <summary>Ends one conversation without ending the doorbell.</summary>
/// <remarks>
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
/// an <see cref="IOException"/> above may already have left it in, and the state a second call would
/// find. Throwing out of the error path would take the loop with it.
/// </remarks>
private static void HangUp(NamedPipeServerStream server)
{
try
{
if (server.IsConnected)
{
server.Disconnect();
}
}
catch (InvalidOperationException)
{
// Already disconnected. Nothing to end.
}
}
}
}
@@ -1,3 +1,4 @@
using System.Runtime.Versioning;
using DodoSSH.Client.Session;
using Velopack;
using Velopack.Sources;
@@ -31,11 +32,18 @@ internal static class UpdateChannels
/// </remarks>
internal static IUpdateChannel ForThisMachine()
{
// Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches
// this too. Velopack has a Linux path — AppImage — but this repository does not build one, so a
// Linux build is a checkout somebody ran, and handing it an UpdateManager would have it poll a
// feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future
// AppImage an addition here rather than a thing that silently already half-happened.
// ◆ Linux is answered first and separately, because it is not a Velopack platform at all.
//
// This used to be the place that said so — a negation over the two packaged platforms, with a note
// that a future AppImage should be an addition here rather than something that had silently
// half-happened. It is an addition, and it is not an AppImage: the Linux head ships as an RPM and
// is updated by the machine's own package manager, so its channel fetches and hands off rather
// than replacing anything. See LinuxUpdateChannel and ADR 0013 rule 11.
if (OperatingSystem.IsLinux())
{
return LinuxChannel();
}
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
{
return new UnavailableUpdateChannel();
@@ -57,6 +65,35 @@ internal static class UpdateChannels
return new UnavailableUpdateChannel();
}
}
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
/// <remarks>
/// <para>
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
/// out.
/// </para>
/// <para>
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
/// differently. The install root is a constant in two places — here and the packaging script — and
/// they are named in each other's comments.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
private static IUpdateChannel LinuxChannel()
{
var root = DesktopChannel.IsNightly ? "/opt/DodoSSH.Nightly" : "/opt/DodoSSH";
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
return string.Equals(here, root, StringComparison.Ordinal)
? new LinuxUpdateChannel(DesktopChannel.Name)
: new UnavailableUpdateChannel();
}
}
/// <summary>
@@ -167,6 +204,25 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
/// </remarks>
private UpdateInfo? found;
/// <summary>
/// The last thing actually fetched, kept separately from the last thing found.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Because the two can disagree, and when they do it is an apply that pays for it.</b> A check
/// overwrites <see cref="found"/>, and the shell now checks while a build is already downloaded and
/// waiting — that is how it replaces a pending build when the feed has moved on, and how it takes one
/// last look on the way to a restart. Either of those can land a newer answer in <c>found</c> and then
/// fail to fetch it, or be given up on halfway; the shell's answer is to restart with the build it
/// already has, and <see cref="Matched"/> resolving only against <c>found</c> would have thrown at it.
/// </para>
/// <para>
/// Which turned a slow forge into a refused restart: the offer on screen was real, the package on disk
/// was real, and the only thing wrong was that this class had forgotten which one it was.
/// </para>
/// </remarks>
private UpdateInfo? fetched;
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
/// <inheritdoc />
@@ -284,7 +340,23 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
// Velopack reports progress as an Action<int> and the rest of this codebase speaks IProgress<T>,
// so the adaptation happens here rather than leaking the older shape into the view models.
return manager.DownloadUpdatesAsync(Matched(update), progress.Report, cancellationToken);
return DownloadAndRememberAsync(Matched(update), progress, cancellationToken);
}
/// <remarks>
/// Recorded after the await and not before, so that a download which threw leaves the previous fetch as
/// the one this channel can still apply. See <see cref="fetched"/>.
/// </remarks>
private async Task DownloadAndRememberAsync(
UpdateInfo info,
IProgress<int> progress,
CancellationToken cancellationToken)
{
await manager
.DownloadUpdatesAsync(info, progress.Report, cancellationToken)
.ConfigureAwait(false);
fetched = info;
}
/// <inheritdoc />
@@ -298,21 +370,39 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
}
/// <remarks>
/// <para>
/// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
/// <see cref="CheckAsync"/> returned; a mismatch is a bug in this application rather than anything a
/// user did, which is why it throws rather than resolving to some safe-looking default.
/// <see cref="CheckAsync"/> returned, or what a download of that then fetched; a mismatch is a bug in
/// this application rather than anything a user did, which is why it throws rather than resolving to
/// some safe-looking default.
/// </para>
/// <para>
/// What was fetched is tried before what was found, because that is the one an apply names and the one a
/// later check cannot take away. See <see cref="fetched"/> for how the two come to disagree.
/// </para>
/// </remarks>
private UpdateInfo Matched(AvailableUpdate update)
{
if (found is not { } info
|| !string.Equals(info.TargetFullRelease.Version.ToString(), update.Version, StringComparison.Ordinal))
if (Names(fetched, update))
{
throw new InvalidOperationException(
$"No update matching {update.Version} has been found by this channel. "
+ "Call CheckAsync and pass back what it returned.");
return fetched!;
}
return info;
if (Names(found, update))
{
return found!;
}
throw new InvalidOperationException(
$"No update matching {update.Version} has been found or fetched by this channel. "
+ "Call CheckAsync and pass back what it returned.");
}
private static bool Names(UpdateInfo? info, AvailableUpdate update) =>
info is not null
&& string.Equals(
info.TargetFullRelease.Version.ToString(),
update.Version,
StringComparison.Ordinal);
}
@@ -9,17 +9,26 @@ namespace DodoSSH.Client.App.Platform;
/// </summary>
/// <remarks>
/// <para>
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware,
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
/// keeps asking for the passphrase — which is the honest answer rather than a degraded one.
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware
/// gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the passphrase — which is
/// the honest answer rather than a degraded one.
/// </para>
/// <para>
/// <b>Both real stores are asked whether they work rather than told that they do.</b> Each
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and deleting it — because on
/// both platforms the provider is present and reports itself present on machines where creating a key
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, and on macOS also every
/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would
/// mean each of those discovering the truth at the moment somebody tried to unlock.
/// <b>Every real store is asked whether it works rather than told that it does.</b> Each
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and discarding it — because on
/// all three platforms the provider is present and reports itself present on machines where creating a key
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, on macOS also every unsigned
/// development build since enclave keys need a signing identity, and on Linux the ordinary case of a
/// working TPM whose device node this user may not open. Inferring from the OS would mean each of those
/// discovering the truth at the moment somebody tried to unlock.
/// </para>
/// <para>
/// <b>The three are not equally strong, and the ordering here does not imply they are.</b> Windows and
/// macOS put the consent gesture inside the platform — a secure desktop and a system Touch ID sheet — so
/// the process asking never sees the secret. The Linux store's PIN is typed into a window this
/// application draws, and only the <em>checking</em> is the TPM's. That is a real difference, it is
/// recorded in ADR 0007 rather than smoothed over, and it is why <see cref="LinuxDeviceKeyStore"/> is
/// tried last rather than first among platforms that could in principle overlap.
/// </para>
/// <para>
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
@@ -33,9 +42,20 @@ namespace DodoSSH.Client.App.Platform;
public static class DesktopDeviceKeyStores
{
/// <summary>The best store this machine supports.</summary>
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
/// <param name="paths">Where a stored key lives.</param>
/// <param name="askForPin">
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
/// raises its own dialog and this process is not in the conversation. A caller with no way to draw one
/// may pass a delegate answering null, which makes the Linux store decline every unlock; that is the
/// right shape for a headless host and the wrong one for the application, which supplies a real dialog
/// in <c>App.axaml.cs</c>.
/// </param>
public static IDeviceKeyStore ForThisMachine(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(askForPin);
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
{
@@ -47,6 +67,11 @@ public static class DesktopDeviceKeyStores
return new MacDeviceKeyStore(paths);
}
if (OperatingSystem.IsLinux() && LinuxDeviceKeyStore.IsSupported())
{
return new LinuxDeviceKeyStore(paths, askForPin);
}
return new UnavailableDeviceKeyStore();
}
}
+21
View File
@@ -37,6 +37,27 @@ internal static class Program
KeepTheWebViewProfileOutOfTheInstallDirectory();
/*
One instance per profile, and nothing above this line is affected by it: the installer's own runs
have already exited inside VelopackApp.Run, and the WebView2 environment variable is a property of
this process rather than of the application.
Claimed here rather than after Avalonia is configured, so that a second launch starts nothing — no
toolkit, no window, no splash flashing on screen before it thinks better of it. What it does
instead is ask the running instance to come forward, which is why this is not simply an exit.
The handle is held for as long as Main runs, which is as long as the message loop does. See
SingleInstance for why it is a file rather than a mutex, and for what the profile scoping buys.
*/
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
using var claim = SingleInstance.Claim(paths);
if (claim is null)
{
return;
}
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
+148 -15
View File
@@ -15,6 +15,28 @@
Showing the last terminal's pane would be a lie, and showing nothing reads as the application having
broken, so this says which machine, as whom, and how far along it is.
── The step list, and why it is amber ───────────────────────────────────────────────────────────────
"How far along it is" used to be one line of prose that never changed after the tab was created, which
made every slow connection look exactly like every hung one. It is now the five steps of actually
getting there, each lit at the moment the handshake reports it — see SshConnectionPhase, which names
only the boundaries a client can genuinely observe. A connection that stops therefore stops on a named
row, and "the host key is being checked" stops being the same screen as "the host is not answering".
Amber for the step in flight, and that is the palette's rule rather than an exception to it. Green is
what is true and purple is what you can press; a step still happening is neither, and it is precisely
the caveat-worth-reading amber exists for — see the remark above Warn in Palette.axaml. Steps behind it
go green as they become true, and the one a refusal landed on goes red. Nothing on the list is drawn in
the accent, because there is nothing on it to press.
Nothing here animates, which is the argument the transfer strip makes for its own track in
TransfersScreen.axaml, arriving at a screen with more reason to want a spinner. A spinner is furniture
invented to fill a state nobody measured; these steps are measured, so the track fills to what has
actually finished and then waits there. Waiting is what waiting looks like.
The list is drawn for both states rather than once per state. A refused connection has the same five
rows and the same track — the difference is only that one row is red and the track stops — and drawing
it twice would be two templates to keep identical for the sake of a colour.
It obeys the occlusion rule the whole window obeys: this is Avalonia-drawn content in the WebView's own
rectangle, so the shell collapses the terminal while it is up. IsTerminalShowing and IsConnectingShowing
are exclusive by construction — a selected tab either has a session or it does not — which is what makes
@@ -24,8 +46,69 @@
can be laid out by a test: WebView2's adapter refuses the headless session's thread.
-->
<UserControl.Styles>
<!--
A rule per lit state over one quiet default, so that the pending weight is stated once and each state
that differs from it is the one line that says how.
-->
<Style Selector="TextBlock.stepcaption">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.stepcaption.done">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="TextBlock.stepcaption.running">
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
<Setter Property="FontWeight" Value="Medium" />
</Style>
<Style Selector="TextBlock.stepcaption.stopped">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Style>
<!--
The marker beside each caption. Fixed width and centred, because four different characters on a
ragged left edge is a list that looks broken; see ConnectionStepViewModel.Mark for which they are.
-->
<Style Selector="TextBlock.stepmark">
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Width" Value="14" />
<Setter Property="TextAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.stepmark.done">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="TextBlock.stepmark.running">
<Setter Property="Foreground" Value="{StaticResource Warn}" />
</Style>
<Style Selector="TextBlock.stepmark.stopped">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
<!--
The track over the list. Amber while the attempt is alive and red once it is not, so that the bar says
the same thing as the row it stopped on rather than staying the colour of something still being waited
for. Chip underneath, matching the transfer strip's track.
-->
<Style Selector="ProgressBar.steptrack">
<Setter Property="Height" Value="5" />
<Setter Property="MinHeight" Value="5" />
<Setter Property="CornerRadius" Value="3" />
<Setter Property="Background" Value="{StaticResource Chip}" />
<Setter Property="Foreground" Value="{StaticResource Warn}" />
</Style>
<Style Selector="ProgressBar.steptrack.stopped">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
</UserControl.Styles>
<Panel>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14" MaxWidth="460"
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
Margin="24">
<StackPanel Spacing="6" HorizontalAlignment="Center">
@@ -38,15 +121,43 @@
</StackPanel>
<!--
Two states, deliberately different. Waiting is an accent line under the host's name; a refusal is
the reason, in the palette's red, because it is the only place the reason will be after the user
navigates away from the screen that started the connection.
Bound to StepsDone against StepCount rather than to a percentage: five steps and a maximum of five
means the bar is a count of things that really finished, and the arithmetic that would turn it into
a percentage is exactly the arithmetic that would start inventing one.
-->
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="12"
Foreground="{StaticResource Accent}" HorizontalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center"
IsVisible="{Binding SelectedTab.IsConnecting}" />
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="7" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="stepmark"
Classes.done="{Binding IsDone}"
Classes.running="{Binding IsRunning}"
Classes.stopped="{Binding IsStopped}"
Text="{Binding Mark}" />
<TextBlock Classes="stepcaption mono"
Classes.done="{Binding IsDone}"
Classes.running="{Binding IsRunning}"
Classes.stopped="{Binding IsStopped}"
Text="{Binding Caption}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
A refusal is the reason, in the palette's red, because it is the only place the reason will be after
the user navigates away from the screen that started the connection. It sits under the list rather
than replacing it: which row it stopped on is half the answer and the sentence is the other half.
-->
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center"
@@ -62,14 +173,36 @@
handshake that finishes afterwards is adopted rather than dropped — see
MainWindowViewModel.CloseTabAsync. Two buttons rather than one with a converted label, because the
two are different decisions and only one of them abandons something still running.
-->
<Button Classes="ghost" HorizontalAlignment="Center" Content="GIVE UP"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
IsVisible="{Binding SelectedTab.IsConnecting}" />
<Button Classes="ghost" HorizontalAlignment="Center" Content="CLOSE TAB"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
IsVisible="{Binding SelectedTab.IsFailed}" />
Beside each, the logs. The step list is this attempt and the log is every other one, which is the
question both a connection taking too long and a connection just refused actually raise — has this
machine ever worked. It is the ordinary rail destination reached the ordinary way rather than a
second log grown inside this card, and leaving by it does not abandon the handshake: the tab stays
in the strip and the card is still here on the way back.
-->
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
<!--
TRY AGAIN rather than RECONNECT, because nothing was ever connected: this tab is a handshake that
was refused. It runs the same command the disconnect strip's RECONNECT does — the vault's own
connect path, into this same tab — and it is worth having here for the reason the commonest
refusal is worth retrying at all: a host that was not up yet is a host that will be in a minute.
Disabled rather than hidden when there is nothing to dial, exactly as the strip's is. See
MainWindowViewModel.CanReconnectSelectedTab.
-->
<Button Classes="ghost" Content="TRY AGAIN" Command="{Binding ReconnectTabCommand}"
CommandParameter="{Binding SelectedTab}"
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
IsVisible="{Binding SelectedTab.IsFailed}" />
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
CommandParameter="{Binding SelectedTab}"
IsVisible="{Binding SelectedTab.IsConnecting}" />
<Button Classes="ghost" Content="CLOSE TAB" Command="{Binding CloseTabCommand}"
CommandParameter="{Binding SelectedTab}"
IsVisible="{Binding SelectedTab.IsFailed}" />
</StackPanel>
</StackPanel>
</Panel>
@@ -0,0 +1,62 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="DodoSSH.Client.App.Views.DevicePinWindow"
Width="420" SizeToContent="Height"
CanResize="False" ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Surface}"
Title="DodoSSH">
<!--
The PIN that unseals this machine's device key, on Linux and nowhere else.
◆ A WINDOW, WHERE EVERY OTHER QUESTION THIS APPLICATION ASKS IS A CARD.
ConfirmDeleteCard, HostKeyCard and the rest are overlays bound to view-model state, and that is the
right shape for a question about the vault: the shell owns the moment and the Android head shows the
same one. This is not that kind of question. It belongs to a platform integration — LinuxDeviceKeyStore
— and the shell it would otherwise live in is shared with the phone, which has a fingerprint and must
never grow a PIN box. Keeping it here keeps DodoSSH.Client.Shell platform-neutral, which is the
property IDeviceKeyStore exists to protect.
◆ AND IT IS DELIBERATELY PLAIN.
Windows and macOS draw their equivalent themselves — a secure desktop and a system sheet — and this one
cannot be either. Dressing it up to look like a system dialog would be teaching people that a
convincing DodoSSH PIN box is a system dialog, which is the phishing this platform's design cannot
prevent and should not rehearse. It says what it is, it names the application, and it stops there.
ADR 0007 records the difference rather than smoothing it over.
-->
<StackPanel Margin="20" Spacing="12">
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
x:Name="Explanation" />
<!--
RevealPassword is off and there is no eye button. A PIN is short enough to retype and this box is
open on a screen somebody may be sharing.
-->
<TextBox x:Name="Pin" PasswordChar="•" PlaceholderText="PIN" FontSize="14" />
<!--
Only when choosing. A mistyped PIN at registration is not recoverable in any useful sense: the key is
sealed to it, and the only way back is to withdraw the device and register again. Asking twice costs
one box.
-->
<TextBox x:Name="Confirm" PasswordChar="•" PlaceholderText="PIN again" FontSize="14"
IsVisible="False" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
x:Name="Problem" IsVisible="False" />
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button x:Name="Cancel" Content="CANCEL" />
<Button x:Name="Accept" Classes="accent" Content="UNLOCK" />
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,203 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Asks for the PIN that unseals this machine's device key.
/// </summary>
/// <remarks>
/// <para>
/// Raised by <see cref="LinuxDeviceKeyStore"/> through the delegate <c>App.axaml.cs</c> supplies, and by
/// nothing else. See the markup for why this is a window rather than a card, and why it is plain.
/// </para>
/// <para>
/// <b>Cancelling is an ordinary answer, not an error.</b> Escape, the close button and CANCEL all resolve
/// to null, which the store reads as "no PIN" and the unlock path answers with the passphrase screen. That
/// is the same contract <c>IDeviceKeyStore.TryLoadAsync</c> documents for a declined fingerprint.
/// </para>
/// </remarks>
public sealed partial class DevicePinWindow : Window
{
/// <summary>
/// The shortest PIN this will accept, and the reason it is this short rather than longer.
/// </summary>
/// <remarks>
/// Four, because the TPM counts wrong answers and stops — which is exactly the property that makes a
/// short secret safe here and does not exist for anything kept in a file. A PIN long enough to be safe
/// without hardware counting would be a second passphrase, and somebody asked for a second passphrase
/// reuses the first.
/// </remarks>
private const int ShortestPin = 4;
private TaskCompletionSource<string?>? answer;
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
public DevicePinWindow()
{
AvaloniaXamlLoader.Load(this);
Accept.Click += OnAccept;
Cancel.Click += OnCancel;
KeyDown += OnKeyDown;
Pin.KeyDown += OnKeyDown;
Confirm.KeyDown += OnKeyDown;
}
/// <summary>
/// Shows the prompt over the main window and answers with what was typed, or null.
/// </summary>
/// <remarks>
/// <para>
/// <b>Marshalled onto the UI thread here rather than at the call site.</b> The store is called from
/// wherever the unlock happens to be running — a background task on launch, a command handler on a
/// button — and none of those should have to know that this ends in a window. Avalonia throws rather
/// than misbehaving if it is wrong, which would turn a device unlock into a crash.
/// </para>
/// <para>
/// A missing owner answers null rather than showing an ownerless window. It means the main window is
/// gone or not up yet, and a modal dialog with nothing behind it is a prompt the user cannot place —
/// which is precisely the shape of the thing this application must not train people to accept.
/// </para>
/// </remarks>
/// <param name="owner">The window to sit over.</param>
/// <param name="purpose">Whether a PIN is being chosen or entered.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static ValueTask<string?> AskAsync(
Func<Window?> owner,
DevicePinPurpose purpose,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(owner);
return new ValueTask<string?>(
Dispatcher.UIThread.InvokeAsync(
async () =>
{
if (owner() is not { } parent)
{
return null;
}
var window = new DevicePinWindow();
window.Prepare(purpose);
using var registration = cancellationToken.Register(window.Close);
return await window.ShowFor(parent).ConfigureAwait(true);
}));
}
private void Prepare(DevicePinPurpose purpose)
{
var choosing = purpose == DevicePinPurpose.Choosing;
Heading.Text = choosing ? "Choose a PIN for this computer" : "Enter your DodoSSH PIN";
Explanation.Text = choosing
? "Your device key is sealed into this computer's TPM. The PIN is checked by the TPM itself, "
+ "which stops answering after a few wrong tries — so a short one is enough, and it only "
+ "ever works on this computer."
: "This unlocks the key sealed into this computer's TPM. Cancel to use your passphrase "
+ "instead.";
Confirm.IsVisible = choosing;
Accept.Content = choosing ? "SET PIN" : "UNLOCK";
}
private async Task<string?> ShowFor(Window parent)
{
answer = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
// Resolves the pending answer however the window goes away — the close button, Escape, or the
// cancellation registration above. Without it a closed window leaves an await that never returns,
// and the unlock path hangs rather than falling back to the passphrase.
Closed += (_, _) => answer.TrySetResult(null);
Pin.Focus();
await ShowDialog(parent).ConfigureAwait(true);
return await answer.Task.ConfigureAwait(true);
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Escape:
Complete(null);
e.Handled = true;
break;
case Key.Enter:
Submit();
e.Handled = true;
break;
default:
break;
}
}
private void OnAccept(object? sender, RoutedEventArgs e) => Submit();
private void OnCancel(object? sender, RoutedEventArgs e) => Complete(null);
/// <summary>
/// Checks what was typed and either answers with it or says what is wrong.
/// </summary>
/// <remarks>
/// Both checks are about a PIN being <em>chosen</em>. Nothing validates a PIN being entered, because
/// the only thing that can judge that is the TPM — refusing a four-character entry here because the
/// stored one was six would be this process deciding something it has no way to know.
/// </remarks>
private void Submit()
{
var pin = Pin.Text ?? string.Empty;
if (!Confirm.IsVisible)
{
if (pin.Length > 0)
{
Complete(pin);
}
return;
}
if (pin.Length < ShortestPin)
{
Show($"A PIN needs at least {ShortestPin} characters.");
return;
}
if (!string.Equals(pin, Confirm.Text ?? string.Empty, StringComparison.Ordinal))
{
Show("Those two do not match.");
return;
}
Complete(pin);
}
private void Show(string problem)
{
Problem.Text = problem;
Problem.IsVisible = true;
}
private void Complete(string? pin)
{
answer?.TrySetResult(pin);
Close();
}
}
@@ -0,0 +1,360 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:import="using:DodoSSH.Client.Import"
x:Class="DodoSSH.Client.App.Views.HetznerImportScreen"
x:DataType="vm:HetznerImportViewModel">
<!--
Importing the servers in a Hetzner Cloud project.
Drawn over the Preferences page inside settings mode, exactly as ImportScreen.axaml is — SettingsNav
stays lit on Preferences, the titlebar says "Back to preferences", and neither is reachable from the nav
rail. See MainWindowViewModel.IsHetznerImportOpen.
── WHY THIS ONE IS A WIZARD AND THE ssh_config IMPORTER IS NOT ────────────────────────────────────────
That screen reads a file that is already on this machine, so it can scan the moment it opens and show a
list. This one cannot: it needs a credential that has to be made in somebody else's console first, and
then it makes a request over the internet. Those are two separate moments and the screen is honest about
it — step one asks for the token, step two shows what the project holds, step three says what was stored.
Both of the first two write nothing.
── THE TOKEN IS NOT STORED ────────────────────────────────────────────────────────────────────────────
It is used for one request and cleared, whether the read succeeded or not. Nothing here writes it to the
vault, to settings or to a log, and the next import asks again — the same argument ImportScreen's key
tick makes for not remembering a "yes". The note above the box says which permission is needed (Read),
that a token is one project rather than one account, and that dedicated servers behind Hetzner's Robot
are not covered by any of this.
── NOTHING IMPORTED HERE CONNECTS ON ITS OWN ──────────────────────────────────────────────────────────
Hetzner has never held the private half of an SSH key, so no key can come with these hosts. That is said
above the button rather than after it, on the same reasoning ImportScreen.axaml gives for its own version
of this sentence: somebody who is not told will conclude the import was broken.
-->
<UserControl.Styles>
<!-- Matching ImportScreen's table exactly: this is the same table about a different source. -->
<Style Selector="Border.importrow:pointerover">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="Border.meaningchip.new">
<Setter Property="Background" Value="{StaticResource LiveWash}" />
</Style>
<Style Selector="Border.meaningchip.new > TextBlock">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="Border.meaningchip.exists">
<Setter Property="Background" Value="{StaticResource Chip}" />
</Style>
<Style Selector="Border.meaningchip.exists > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Border.meaningchip.warn">
<Setter Property="Background" Value="{StaticResource WarnWash}" />
</Style>
<Style Selector="Border.meaningchip.warn > TextBlock">
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
</Style>
</UserControl.Styles>
<Grid MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch" RowDefinitions="Auto,Auto,*">
<!-- ============ HEADER ============ -->
<StackPanel Grid.Row="0" Spacing="10">
<TextBlock Classes="settingstitle" Text="Import from Hetzner Cloud" />
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="label" Text="{Binding StepLabel}" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding StepTitle}" FontSize="12"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="0,12,0,0"
TextWrapping="Wrap" />
<Panel Grid.Row="2" Margin="0,18,0,0">
<!-- ============ STEP 1 · THE TOKEN ============ -->
<Grid IsVisible="{Binding IsTokenStep}" RowDefinitions="Auto,*">
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
<StackPanel Spacing="14">
<TextBlock Text="Cloud API token" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="{Binding TokenNote}" />
<TextBox Text="{Binding Token}" PasswordChar="•" Height="42" FontSize="13"
FontFamily="{StaticResource MonoFont}"
PlaceholderText="64 characters, from Security → API tokens">
<!-- Enter reads the project, so a pasted token does not need the mouse to go anywhere. -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}" />
</TextBox.KeyBindings>
</TextBox>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Classes="ghost" Height="42" Content="CANCEL" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences. Nothing has been sent." />
<Button Classes="accent" Height="42" Content="READ THE PROJECT"
Command="{Binding ScanCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Lists the project's servers. Nothing is stored, and the token is forgotten as soon as the list arrives." />
</StackPanel>
</StackPanel>
</Border>
</Grid>
<!-- ============ STEP 2 · WHAT THE PROJECT HOLDS ============ -->
<Grid IsVisible="{Binding IsChooseStep}" RowDefinitions="Auto,Auto,*,Auto">
<!--
What every imported host gets: which address, which login user, which port. One set of answers for
the whole import rather than one per row, because an estate is reached the same way throughout —
a per-server dropdown would be forty answers to one question.
-->
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="20,18">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
<TextBlock Grid.Column="0" Classes="label" Text="DIAL" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<!--
Chips rather than radio buttons: this application has no RadioButton style, and Fluent's
own beside a row of chips reads as belonging to a different program. Button.chiptoggle is
the tag picker's control, which is already "one of these is on" drawn as a chip.
-->
<Button Classes="chiptoggle" Content="PUBLIC IPv4"
Classes.worn="{Binding UsesPublicIPv4}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv4}" />
<Button Classes="chiptoggle" Content="PUBLIC IPv6"
Classes.worn="{Binding UsesPublicIPv6}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv6}" />
<Button Classes="chiptoggle" Content="PRIVATE NETWORK"
Classes.worn="{Binding UsesPrivate}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.Private}" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="label" Text="USER" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" Margin="14,0,0,0" />
<TextBox Grid.Column="3" Text="{Binding Username}" Width="120" Height="34"
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
ToolTip.Tip="Every Hetzner Linux image puts the project's SSH keys on root and creates no other account." />
<TextBox Grid.Column="4" Text="{Binding PortText}" Width="66" Height="34"
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
ToolTip.Tip="The SSH port every imported host gets." />
</Grid>
<!--
◆ WHERE THEY LAND, AND WHAT THEY AUTHENTICATE WITH. The two answers this import used to
decide on somebody's behalf: the active vault, and nothing.
A project belongs to a team more often than to a person, so the vault is asked rather than
assumed — see HetznerImportViewModel.VaultChoices, and the host editor's own picker for why
this is its own selection rather than the keychain screen's standing preference. It is hidden
at one vault, which is a question with one answer.
The key is the half Hetzner cannot supply and the keychain usually already has: the public
half went to Hetzner when the servers were made, the private half never did. Pointing forty
hosts at it here is one answer; without it, it was forty visits to forty host editors. The
picker is hidden entirely on a keychain with no keys in it.
-->
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="18">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding HasVaultChoice}">
<TextBlock Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<ComboBox Width="230" Height="34" ItemsSource="{Binding VaultChoices}"
SelectedItem="{Binding SelectedVault}"
ToolTip.Tip="Which vault the imported hosts are written into. A shared one is readable by everybody who holds its key.">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding HasKeyChoice}">
<TextBlock Classes="label" Text="KEY" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<ComboBox Width="260" Height="34" ItemsSource="{Binding KeyChoices}"
SelectedItem="{Binding SelectedKey}"
ToolTip.Tip="A key already in your keychain, which every imported host is pointed at. Nothing is copied — the host stores the key's id, exactly as one bound by hand does.">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerKeyChoice">
<TextBlock Text="{Binding Label}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Grid>
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17" TextWrapping="Wrap"
Text="{Binding AddressNote}" />
<TextBlock Classes="hint" FontSize="11" IsVisible="{Binding !IsPortValid}"
Foreground="{StaticResource WarnText}"
Text="That is not a port. Give a number between 1 and 65535." />
</StackPanel>
</Border>
<!-- What the project could not be made to say, at project level. -->
<Border Grid.Row="1" Margin="0,14,0,0" Padding="14,10" CornerRadius="10"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" IsVisible="{Binding HasWarnings}">
<ItemsControl ItemsSource="{Binding Warnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11.5"
TextWrapping="Wrap" Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- ============ THE TABLE ============ -->
<Border Grid.Row="2" Margin="0,14,0,0" CornerRadius="12" Background="{StaticResource Pane}"
BorderBrush="{StaticResource Border}" BorderThickness="1" ClipToBounds="True"
IsVisible="{Binding HasRows}">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*">
<!-- A Button rather than a CheckBox, for the reason ImportScreen.axaml's own header gives. -->
<Button Grid.Column="0" Classes="flat" Width="18" Height="18" Padding="0"
VerticalAlignment="Center" Command="{Binding ToggleAllCommand}"
ToolTip.Tip="Tick or untick every row this address choice can reach">
<Panel Width="18" Height="18">
<Border CornerRadius="5" Background="{StaticResource Accent}"
IsVisible="{Binding AllTicked}">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5CA;" FontSize="13"
Foreground="White" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<Border CornerRadius="5" BorderBrush="{StaticResource BorderMid}" BorderThickness="1.5"
IsVisible="{Binding !AllTicked}" />
</Panel>
</Button>
<TextBlock Grid.Column="1" Classes="label" Text="SERVER" FontSize="9.5" LetterSpacing="1"
Margin="14,0,0,0" />
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="WHAT THIS MEANS" FontSize="9.5"
LetterSpacing="1" />
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerRowViewModel">
<Border Classes="importrow" CornerRadius="8">
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*" Margin="12" MinHeight="20">
<!--
Disabled where the address choice cannot reach this server, which is the private
case: a server on no private network is not importable and must not be silently
given a public address instead.
-->
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}"
IsEnabled="{Binding CanImport}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="14,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Kind}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Grid.Column="5" Classes="meaningchip" CornerRadius="5" Padding="8,3"
HorizontalAlignment="Left" VerticalAlignment="Center"
Classes.new="{Binding IsMeaningNew}"
Classes.exists="{Binding IsMeaningExisting}"
Classes.warn="{Binding IsMeaningWarned}" ToolTip.Tip="{Binding Meaning}">
<TextBlock Text="{Binding Meaning}" FontSize="10" FontWeight="SemiBold"
MaxWidth="230" TextTrimming="CharacterEllipsis" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
<!-- ============ FOOTER ============ -->
<StackPanel Grid.Row="3" Margin="0,16,0,0" Spacing="14">
<!--
Said before the button. Whether a credential comes with the host is the difference between a
bookmark that connects and one that asks for a password, and nothing about a Hetzner project can
supply one.
-->
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="{Binding AuthenticationNote}" />
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="hint" FontSize="12" VerticalAlignment="Center"
Text="{Binding SelectionSummary}" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN"
Command="{Binding RestartCommand}"
ToolTip.Tip="Back to the token box, for another project. This list goes with it — the token it came from was not kept." />
<Button Classes="ghost" Height="44" Content="CANCEL" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences. Nothing is stored." />
<Button Classes="accent" Height="44" Content="{Binding ImportLabel}"
Command="{Binding ImportCommand}" IsEnabled="{Binding CanImport}" />
</StackPanel>
</Grid>
</StackPanel>
</Grid>
<!-- ============ STEP 3 · WHAT WAS STORED ============ -->
<Grid IsVisible="{Binding IsDoneStep}" RowDefinitions="Auto,*,Auto">
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
<StackPanel Spacing="12">
<TextBlock Text="What happens next" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="{Binding AuthenticationNote}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="The list this came from is one press away. Going back to it and importing the rest costs nothing — what was just stored is marked as already here. It is the list this scan produced, though, not a fresh one: a server made in the Hetzner console since then is not in it." />
</StackPanel>
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right"
Margin="0,16,0,0">
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN" Command="{Binding RestartCommand}"
ToolTip.Tip="Back to the token box, for another project." />
<Button Classes="ghost" Height="44" Content="BACK TO THE LIST"
Command="{Binding ReviewCommand}"
ToolTip.Tip="The same servers, with what you just stored marked as already here." />
<Button Classes="accent" Height="44" Content="DONE" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences." />
</StackPanel>
</Grid>
</Panel>
</Grid>
</UserControl>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Importing hosts from a Hetzner Cloud project.
/// </summary>
/// <remarks>
/// <para>
/// A task rather than a destination, reached from Preferences and not from the nav rail, and drawn inside
/// settings mode over the Preferences page — the same shape <see cref="ImportScreen"/> has, for the same
/// reasons, including having no keyboard target of its own to hand back.
/// </para>
/// <para>
/// No code behind it, unlike <see cref="ImportScreen"/>, and the difference is worth a sentence: that screen
/// listens for bubbled tick events to keep its footer count honest, because its rows have no way to reach the
/// list they are in. This one's view model subscribes to each row's own change notification instead — see
/// <c>HetznerImportViewModel.OnRowChanged</c> — which puts the same wiring where the count actually lives
/// rather than in a routed-event handler on a control.
/// </para>
/// </remarks>
internal sealed partial class HetznerImportScreen : UserControl
{
public HetznerImportScreen() => InitializeComponent();
}
@@ -584,6 +584,56 @@
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Making a credential without leaving the host. The moment one is wanted is this one: somebody
is deciding how a host authenticates and finds the password is not in the keychain yet, and
sending them to the other screen to add it would lose the half-typed host they are standing
in. Same argument as the new-tag box further down, same immediate write, same honest
consequence — the credential stays if this editor is cancelled, because a host can only name
an id that exists.
A button beside the picker rather than an entry inside it. Every row of that list is a
binding the host can have; "make a new one" is an action, and as an entry it would sit in the
box afterwards describing a state no host can be in.
-->
<Button Classes="ghost" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
FontSize="10.5" Height="28" Padding="10,0"
IsVisible="{Binding !IsAddingEditorCredential}"
Command="{Binding BeginEditorCredentialCommand}"
ToolTip.Tip="Adds a credential to the keychain and binds this host to it" />
<Border CornerRadius="12" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
IsVisible="{Binding IsAddingEditorCredential}">
<StackPanel Spacing="6">
<TextBlock Classes="label" Text="NEW CREDENTIAL" FontSize="10" />
<TextBox Text="{Binding EditorNewCredentialLabel}" PlaceholderText="name" Height="36" />
<!--
Optional, and what makes a credential worth being its own item: one account on twenty
machines is rotated in one place. Left blank, this host's own username is used.
-->
<TextBox Text="{Binding EditorNewCredentialUsername}" Height="36"
PlaceholderText="username (blank: use this host's own)" />
<!-- Masked, on the reasoning the keychain's own password box carries. -->
<TextBox Text="{Binding EditorNewCredentialPassword}" PlaceholderText="password"
PasswordChar="•" Height="36">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorCredentialCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBox Text="{Binding EditorNewCredentialNotes}" PlaceholderText="notes"
AcceptsReturn="True" Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="Added to the keychain as soon as you press ADD, so it stays even if you cancel this host. Renaming and deleting are on the keychain screen." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="ADD"
Command="{Binding AddEditorCredentialCommand}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding CancelEditorCredentialCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!--
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
+69 -13
View File
@@ -98,6 +98,18 @@
<Grid ColumnDefinitions="*,Auto">
<!--
── 26 DOWN EACH SIDE, the same inset Keychain, Snips, Logs and Pins all take. ──────────────────────
Those four say it once, as Margin="26" on their own root; this screen repeats it on each of the four
rows below, and it has to. The board's ScrollViewer is the last row and is deliberately full-bleed, so
that its scrollbar rides the pane's own edge rather than floating 26 pixels inside it — a root margin
would inset the bar with everything else. It would also inset the drawer in the second column, which
draws its own edge and wants none.
It was 16 and 20 until this pass, which put the Hosts header a visible step left of and above every
other screen's. Four numbers rather than one is the cost of the two exceptions above; changing one of
them means changing all four.
-->
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
<!--
@@ -107,7 +119,7 @@
buttons over a board of forty is a pair whose subject the user has to work out. The group's own
Edit/Move/Delete sit on its own heading's menu for the same reason.
-->
<Grid Grid.Row="0" Margin="16,20,16,16" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
<Grid Grid.Row="0" Margin="26,26,26,16" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Text="Hosts" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
@@ -219,7 +231,7 @@
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
who has found this box should know about the one that also connects on Enter.
-->
<Border Grid.Row="1" Margin="16,0,16,16">
<Border Grid.Row="1" Margin="26,0,26,16">
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10"
FontFamily="{StaticResource MonoFont}"
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
@@ -232,7 +244,7 @@
one of them sits here, above the board, rather than laid over it: a card over the cards would hide
the very ticks or the very group it is asking about.
-->
<StackPanel Grid.Row="2" Margin="16,0,16,12" Spacing="10">
<StackPanel Grid.Row="2" Margin="26,0,26,12" Spacing="10">
<!--
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
@@ -434,7 +446,7 @@
HostsScreen.axaml.cs.
-->
<ScrollViewer Grid.Row="3" x:Name="Scroll" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,0,16,20" Spacing="16">
<StackPanel Margin="26,0,26,26" Spacing="16">
<!--
Named because it is where keyboard focus lands when the terminal gives it back, and because
@@ -445,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>
@@ -460,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
@@ -557,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}" />
@@ -588,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">
@@ -690,7 +746,7 @@
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
IsVisible="{Binding HasPins}">
<StackPanel Orientation="Horizontal" Spacing="3">
<TextBlock Text="&#xE946;" FontFamily="{StaticResource IconFont}"
<TextBlock Text="&#xF10D;" FontFamily="{StaticResource IconFont}"
FontSize="11" Foreground="{StaticResource TextFaint}" />
<TextBlock Classes="mono" Text="{Binding PinCount}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" />
@@ -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();
+37 -13
View File
@@ -148,12 +148,26 @@
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="0,0,12,12" ClipToBounds="True">
<Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
<views:SessionHeader Grid.Row="0"
OpenLabel="Open terminal"
OpenCommand="{Binding OpenTerminalForFilesHostCommand}"
EmptyText="{Binding Transfers.Status}" />
<views:TransfersScreen Grid.Row="1" DataContext="{Binding Transfers}" />
<!--
v5c-4: two rows rather than three. The 60-pixel host header that used to sit above this
screen is gone; the address and the "Open terminal" button it carried are in the
sidebar now — see SessionSidebar.axaml — and the pane keeps the height. Its third
binding, the Transfers.Status line it printed while no host was open, is not moved
either: TransfersScreen draws that same string itself, both in its own empty state and
beside the remote pane's DISCONNECT once something is open.
-->
<!--
Three rows since the disconnect strip arrived, and it is the same control the terminal
surface draws — see SessionEndedBar.axaml, which resolves which surface it is about
through the shell rather than through a binding of its own. Auto and empty except while
the file session has been lost, so an ordinary browse pays nothing for it.
Note the DataContext: this row is the shell's rather than the transfers screen's, which
is why the strip sits here and not inside TransfersScreen.axaml.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
<views:SessionEndedBar Grid.Row="1" />
<views:SessionStatusBar Grid.Row="2" />
</Grid>
<!--
@@ -268,14 +282,23 @@
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="0,0,12,12" ClipToBounds="True">
<Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
<views:SessionHeader Grid.Row="0"
OpenLabel="Open SFTP"
OpenCommand="{Binding SelectFilesHostCommand}"
OpenCommandParameter="{Binding SelectedTab}"
EmptyText="no terminals open · press + or Ctrl+K, or choose a host and press Connect" />
<!--
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
this Grid is only drawn on the terminal surface, and the surface with no tab open already
answers for itself in the tab row's own "+" and in the connecting card below.
-->
<!--
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
and empty except while the selected tab's shell has stopped, so it costs the terminal
nothing in the ordinary case — and when it is up it takes its 34 pixels off the pane rather
than drawing over it, which is the only way Avalonia content can sit near the renderer at
all. See SessionEndedBar.axaml.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
<Panel Grid.Row="1" Background="{StaticResource Pane}">
<Panel Grid.Row="0" Background="{StaticResource Pane}">
<!--
The other thing that can be in the terminal's rectangle: a tab whose session does not
@@ -300,6 +323,7 @@
</Panel>
<views:SessionEndedBar Grid.Row="1" />
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
</Grid>
<!--
@@ -54,6 +54,21 @@ internal sealed partial class MainWindow : Window
};
}
/// <summary>
/// Colours the system-drawn frame the moment there is a handle to colour it on.
/// </summary>
/// <remarks>
/// <c>OnOpened</c> and not the constructor: the window has no platform handle until it is shown, and
/// <see cref="NativeWindowFrame"/> does nothing without one. See that class for what the frame is and
/// why <c>BorderOnly</c> still has one.
/// </remarks>
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
NativeWindowFrame.MatchTo(this);
}
/// <summary>
/// Asks the Linux backend for the one mode it can actually draw inside this window.
/// </summary>
@@ -268,6 +283,7 @@ internal sealed partial class MainWindow : Window
if (shell is { } previous)
{
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
previous.PropertyChanged -= OnShellPropertyChanged;
}
@@ -286,6 +302,7 @@ internal sealed partial class MainWindow : Window
wasUnlocked = viewModel.IsUnlocked;
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
viewModel.PropertyChanged += OnShellPropertyChanged;
}
@@ -298,6 +315,15 @@ internal sealed partial class MainWindow : Window
/// </remarks>
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
/// <remarks>
/// The same call for a session that was already open and has just been typed into from the sidebar —
/// see <see cref="MainWindowViewModel.TerminalFocusRequested"/>. Posted like every other path here,
/// although nothing was revealed this turn: the post also re-checks that a terminal is still showing,
/// which is what keeps this from stealing the keyboard if the insert landed the user on the snippets
/// screen instead.
/// </remarks>
private void OnTerminalFocusRequested(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
/// <remarks>
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
@@ -0,0 +1,133 @@
using System.Runtime.InteropServices;
using Avalonia.Controls;
using Avalonia.Media;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Paints the frame Windows still draws around a <c>BorderOnly</c> window in the application's own
/// colour, so the top edge stops reading as a leftover system titlebar.
/// </summary>
/// <remarks>
/// <para>
/// <b>The symptom this exists for:</b> a pale strip across the very top of the window, a few pixels
/// tall and plainly not part of the application — most obvious on a machine with "show accent colour
/// on title bars and window borders" turned on, where it comes out blue against a near-black shell.
/// </para>
/// <para>
/// It is not <c>TitleBar.axaml</c> leaking and it is not a margin. It is DWM, and the reason it is
/// there is visible in Avalonia's own Win32 backend: <c>WindowImpl.UpdateWindowProperties</c> gives a
/// <see cref="WindowDecorations.BorderOnly"/> window <c>WS_BORDER | WS_THICKFRAME</c> and then calls
/// <c>DwmExtendFrameIntoClientArea</c> with one-pixel margins on all four sides. So the compositor
/// owns a hairline of every edge of this window, and it fills that hairline with the system's caption
/// and border colours — which are chosen by the user's personalisation settings and have no reason to
/// resemble <c>CanvasColor</c>. The window is the wrong place to look for the pixels; they were never
/// painted by anything in this tree.
/// </para>
/// <para>
/// The fix is to tell DWM what colour to use rather than to try to cover it. <c>DWMWA_BORDER_COLOR</c>
/// and <c>DWMWA_CAPTION_COLOR</c> arrived in Windows 11 21H2 and are exactly that; both are set to the
/// window's own background, so the hairline still exists — the resize grip is on it, and the drop
/// shadow hangs off it — and simply cannot be seen. Deliberately <em>not</em> <c>DWMWA_COLOR_NONE</c>,
/// which removes the border outright: on a dark desktop that leaves a near-black window with no edge
/// at all, which trades one visual defect for another.
/// </para>
/// <para>
/// Windows 10 gets the dark-mode attribute and nothing else, and that is the whole of what is
/// available there: the two colour attributes are unsupported, <c>DwmSetWindowAttribute</c> answers
/// <c>E_INVALIDARG</c>, and the calls do nothing. Hence the ignored return values — every attribute
/// here is an improvement where it lands and a no-op where it does not, so there is nothing for a
/// caller to handle and nothing worth logging on a path that runs once at startup.
/// </para>
/// </remarks>
internal static class NativeWindowFrame
{
/// <summary>Windows 11 21H2 and later: the colour of the frame border.</summary>
private const int BorderColorAttribute = 34;
/// <summary>Windows 11 21H2 and later: the colour of the caption, including the extended frame.</summary>
private const int CaptionColorAttribute = 35;
/// <summary>
/// Windows 10 1903 and later: draw the frame in the dark palette.
/// </summary>
/// <remarks>
/// Redundant on Windows 11, where the two colour attributes above name the colours outright, and it
/// is set anyway because it is the only one of the three that Windows 10 honours. The build before
/// 1903 used attribute 19 for this; that is not chased here, because a border on an OS release that
/// left support in 2020 is not worth a second interop call.
/// </remarks>
private const int DarkModeAttribute = 20;
/// <summary>
/// Matches <paramref name="window"/>'s system-drawn frame to the colour it paints itself.
/// </summary>
/// <remarks>
/// Call once the window has a handle — <c>OnOpened</c> is the first such moment. Calling earlier
/// finds no platform handle and silently does nothing, which is the defect this replaced: the strip
/// is only visible once the window is on screen, so a call that ran too early looks like a fix that
/// does not work rather than a fix that never ran.
/// </remarks>
internal static void MatchTo(Window window)
{
// Every attribute below is a DWM one, and DWM is Windows. Elsewhere the frame is drawn by the
// platform's own compositor and there is nothing here to say to it.
if (!OperatingSystem.IsWindows())
{
return;
}
if (window.TryGetPlatformHandle()?.Handle is not { } handle || handle == IntPtr.Zero)
{
return;
}
Set(handle, DarkModeAttribute, 1);
// The window's own Background rather than a named resource, so the frame cannot drift from the
// canvas when the palette moves. A brush that is not solid — a gradient, or nothing set at all —
// has no single colour to match, and leaving the system's own is better than inventing one.
if (window.Background is not ISolidColorBrush { Color: var canvas })
{
return;
}
var reference = ColorRef(canvas);
Set(handle, BorderColorAttribute, reference);
Set(handle, CaptionColorAttribute, reference);
}
/// <summary>
/// Sets one integer-valued DWM attribute, and discards the answer.
/// </summary>
/// <remarks>
/// The discard is the point of this method existing rather than being three call sites. Every
/// attribute here is unsupported on some Windows this application runs on, and unsupported means
/// <c>E_INVALIDARG</c> and no change — which is the intended outcome on that OS, not a failure, so
/// there is nothing for the caller to do with the <c>HRESULT</c> and nothing worth logging once at
/// startup. Written once, with the reasoning, rather than left implicit at each call.
/// </remarks>
private static void Set(IntPtr window, int attribute, int value) =>
_ = DwmSetWindowAttribute(window, attribute, ref value, sizeof(int));
/// <summary>
/// Packs <paramref name="color"/> into a Win32 <c>COLORREF</c>.
/// </summary>
/// <remarks>
/// <c>0x00BBGGRR</c> — blue in the high byte, not red, and the alpha byte must be zero. Getting the
/// order wrong produces a plausible-looking wrong colour rather than an error, which is the kind of
/// bug that survives a glance at the window.
/// </remarks>
private static int ColorRef(Color color) => color.R | (color.G << 8) | (color.B << 16);
/// <remarks>
/// <c>DllImport</c> rather than <c>LibraryImport</c>, for the reason
/// <see cref="NativeKeyboardFocus"/> gives at its own P/Invoke: the generated form needs
/// <c>AllowUnsafeBlocks</c> across a project that handles key material, and this signature is
/// blittable, so there is no marshalling for it to improve.
/// </remarks>
#pragma warning disable SYSLIB1054
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr window, int attribute, ref int value, int size);
#pragma warning restore SYSLIB1054
}
+42 -45
View File
@@ -40,8 +40,9 @@
Both are still one click away; see the popover below the user chip. The chip itself carries the signed-
in identity this application actually has — a display name and, where the server sent one, an email —
which is also new: the titlebar drew an account name and a vault chip before this pass and does not any
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the deviations this rail keeps on
purpose: Pins, which the mock has no screen for at all, and the S3 segment above.
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the one deviation this rail still
keeps on purpose: the S3 segment above. Pins was the other, and it is gone — see the remark where that
row used to sit, between Keys and Snips.
Buttons rather than a TabStrip or a ListBox, still, for the reason the v3 remark gave: all three hold
the selection themselves, so a click would move the highlight before the shell decided anything, and a
@@ -128,19 +129,16 @@
</Button>
<!--
KEPT — the mock has no screen for approved host keys at all; see the file-level remark. push_pin
is the same codepoint HostsScreen.axaml already draws for a host's own pin badge, reused rather
than picked afresh so the one concept reads as one glyph everywhere it appears.
◆ NO Pins ROW. The pins screen is still here and still reached in one click — from "Host keys"
on the Keys screen's own header, which is where a list of approved host keys belongs: they are
keychain material, and that button was already the second way to reach them. Two rail rows away
from each other, both landing on the same screen, is a rail that has to be read twice.
It is also the last of the rail's own deviations from the mock to go. The row was kept in v5b on
the grounds that the design has no screen for approved host keys at all — see the file-level
remark — which is true of the design and was never a reason for a rail entry once the keychain
had a door to the same place.
-->
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE946;" />
<TextBlock Classes="navlabel" Text="Pins" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
Command="{Binding ShowScreenCommand}"
@@ -191,17 +189,22 @@
</Grid>
<FlyoutBase.AttachedFlyout>
<Flyout Placement="TopEdgeAlignedLeft">
<StackPanel Width="227" Spacing="8">
<!--
FlyoutPresenterClasses, because a Flyout's own panel is not in this markup's visual tree to be
styled from here — see FlyoutPresenter.poppanel in App.axaml for what the class carries and why
the shared popup rule was not simply widened to cover it.
-->
<Flyout Placement="TopEdgeAlignedLeft" FlyoutPresenterClasses="poppanel">
<StackPanel Width="227" Spacing="4">
<!--
The real email, when the server sent one — verified against MainWindowViewModel.Email rather
than assumed, and simply absent from the popover when it has not. No " · Org" suffix: there
is no organisation concept behind a vault, only the vault itself, which the rows below name.
-->
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1"
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1" Margin="11,4,11,6"
Foreground="{StaticResource TextGhost}"
Text="{Binding Email}"
Text="{Binding Email}" TextTrimming="CharacterEllipsis"
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!--
@@ -246,42 +249,36 @@
</Grid>
</Button>
<Border Height="1" Background="{StaticResource BorderMid}" />
<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">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE8B8;" FontSize="12"
Foreground="{StaticResource Text}" />
Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel>
</Button>
<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 TextGhost}" />
</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 TextGhost}" />
</StackPanel>
</Button>
<Border Height="1" Background="{StaticResource BorderMid}" />
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
<!--
The existing sign-out flow, with its own confirm card — see
@@ -291,7 +288,7 @@
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE9BA;" FontSize="12"
Foreground="{StaticResource Text}" />
Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel>
</Button>
@@ -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"/>.
@@ -87,12 +87,27 @@
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
Two readings stacked against the two on the left, and in the same order: what this row
IS above, how it is reached below.
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
The palette searches every readable vault at once, so without it two hosts a team and a
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
vault name would otherwise take its width out of the host name beside it.
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
connect here is SSH, so printing that word would be a constant dressed up as a reading;
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
-->
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="{Binding Authentication}"
FontSize="11" Margin="10,0,0,0" VerticalAlignment="Center" />
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
HorizontalAlignment="Right" MaxWidth="150"
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
FontSize="11" HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
@@ -0,0 +1,106 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.SessionEndedBar"
x:DataType="vm:MainWindowViewModel">
<!--
── What a session says once it has stopped ──────────────────────────────────────────────────────────
A strip between the pane and the status bar, shown only while the session in front of the user has
ended. It names which ending it was, prints the sentence that came with it, and offers the one thing
worth offering: dialling the machine again.
◆ ONE CONTROL ON BOTH SURFACES, and the shell is what resolves which it is drawn over. A terminal has
two endings to tell apart and a keychain entry to check before it can offer a redial; a file session has
one ending and already captured what it would dial. None of that is a decision a view should be making,
so every binding here is a MainWindowViewModel property that has already asked which surface is showing
— see SessionEndedText, CanReconnectSession and SessionEndedDismissLabel. The second button is even
labelled from the shell, because CLOSE TAB and DISCONNECT are noticeably different acts and one vague
word covering both would be the wrong kind of reuse.
◆ IT IS A ROW OF ITS OWN RATHER THAN SOMETHING DRAWN OVER THE PANE, and on the terminal surface that is
not a style choice. The renderer is a native child view that composites above everything Avalonia draws,
so a banner overlapping its rectangle would either be invisible or would have to collapse the terminal —
and collapsing it is exactly wrong here, because the scrollback is what the user is reading. A dead
shell's last words are usually why it died. So this takes its own Auto row and the pane simply gets 34
fewer pixels while it is up. The file surface has no such constraint and takes the same row anyway,
because a listing left on screen is worth the same as a scrollback: it is where the user was, and it is
what makes RECONNECT mean something. See MainWindow.axaml for both placements.
◆ THE SENTENCE IS HERE AS WELL AS IN THE PANE ON PURPOSE. terminal.js writes the same string into the
scrollback as a grey rule, which is right and is not enough: a machine that printed a page of shutdown
messages on its way down has pushed that line off the screen, and a session that ended while the user
was on another tab was never on screen at all. This copy does not scroll.
── the two endings, and why they are not one colour ─────────────────────────────────────────────────
SHELL EXITED is grey. Somebody typed `exit`, or a login shell ended; nothing is wrong and an alarm
would be noise. DISCONNECTED is the palette's red, because the connection went out from under a session
that had not finished — a reboot, a network drop, an sshd that was killed — and it is the one of the two
that means the machine may still be there and worth dialling. The dot beside the word says the same
thing a second way, for anybody who cannot tell this design's red from its grey.
── the buttons ──────────────────────────────────────────────────────────────────────────────────────
RECONNECT is disabled rather than hidden when there is nothing to dial — a tab typed in by hand, or one
whose host has left the keychain — because the answer to "why can I not reconnect this" is better given
by a control that is visibly refusing than by one that is not there. See
MainWindowViewModel.CanReconnectSession, which is what draws that line on each surface, and
ReconnectSessionAsync for what the button actually does: each surface's own connect path, back into the
thing that stopped. On the file side that includes walking back to the directory the pane was showing,
which is what makes it a reconnect rather than a connect.
The second button is the moment the other reasonable answer is "I have read it, take it away" — and
hunting for a 16-pixel cross in the strip above is not the shape of that answer.
-->
<UserControl.Styles>
<Style Selector="TextBlock.endword">
<Setter Property="FontFamily" Value="Montserrat" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="9.5" />
<Setter Property="LetterSpacing" Value="1.2" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="TextBlock.endword.dropped">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Style>
</UserControl.Styles>
<Border Height="34" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Classes="dot" Width="7" Height="7" Classes.dropped="{Binding IsSessionDropped}"
VerticalAlignment="Center" />
<TextBlock Classes="endword" Classes.dropped="{Binding IsSessionDropped}"
Text="{Binding SessionEndedText}" />
</StackPanel>
<!--
Trimmed rather than wrapped: this row is one line tall and the sentences it draws are short, but a
transport error can hand over whatever the operating system called it, and a bar that grew to fit
one would take the height out of the terminal above it.
-->
<SelectableTextBlock Grid.Column="1" Classes="mono" FontSize="11.5" Margin="16,0"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
Text="{Binding SessionEndedDetail}"
ToolTip.Tip="{Binding SessionEndedDetail}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<Button Classes="ghost" Content="RECONNECT"
Command="{Binding ReconnectSessionCommand}"
IsEnabled="{Binding CanReconnectSession, FallbackValue=False}"
ToolTip.Tip="Opens this connection again, in place." />
<Button Classes="ghost" Content="{Binding SessionEndedDismissLabel}"
Command="{Binding DismissEndedSessionCommand}" />
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>What the session shell says once the session in front of the user has stopped.</summary>
/// <remarks>
/// No code of its own: the word, the sentence and both buttons are the shell's, including which surface
/// they are about — see the markup. In its own file for the reason every other card here is: nothing inside
/// <c>MainWindow</c> can be laid out by a test, because WebView2's adapter refuses the headless session's
/// thread.
/// </remarks>
internal sealed partial class SessionEndedBar : UserControl
{
public SessionEndedBar() => InitializeComponent();
}
@@ -1,49 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.SessionHeader"
x:Name="Root"
x:DataType="vm:MainWindowViewModel">
<!--
── v5b's session shell host header ──────────────────────────────────────────────────────────────────────
60px, DeepChrome, atop the pane both the terminal and the SFTP surface hold. Per the design, minus the
three deviations design-notes/v5b-fidelity-notes.md records: no OS label, no latency reading, no "Port
forward" button — none of those are facts this application has.
◆ THE ONE FACT LEFT is the address, read off MainWindowViewModel.SessionAddress — which is already the
surface-aware property, so this control asks no question about which screen it is on. What differs
between the two usages is only the cross-surface button: <see cref="OpenLabel"/>, <see cref="OpenCommand"/>
and the empty-state copy, all handed in from MainWindow.axaml rather than branched on here.
-->
<Border Height="60" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="24,0,20,0">
<!--
The address, only while a session/host context is active — see SessionAddress's own remark for what
"active" means on each surface. The empty state takes its place otherwise, in the idiom every other
screen's own "nothing yet" sentence already uses: TextFaint, sentence case, no punctuation implying a
form to fill in.
-->
<TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold" FontSize="14"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center"
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding #Root.EmptyText}" TextTrimming="CharacterEllipsis"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNullOrEmpty}}" />
<Button Grid.Column="1" Classes="headerghost"
Content="{Binding #Root.OpenLabel}"
Command="{Binding #Root.OpenCommand}"
CommandParameter="{Binding #Root.OpenCommandParameter}" />
</Grid>
</Border>
</UserControl>
@@ -1,58 +0,0 @@
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The v5b session shell's host header: the address, and a ghost button that crosses to the other surface.
/// See the remark at the top of SessionHeader.axaml.
/// </summary>
internal sealed partial class SessionHeader : UserControl
{
/// <summary>What the cross-surface ghost button says — "Open SFTP" or "Open terminal".</summary>
internal static readonly StyledProperty<string?> OpenLabelProperty =
AvaloniaProperty.Register<SessionHeader, string?>(nameof(OpenLabel));
/// <summary>What the cross-surface ghost button runs.</summary>
/// <remarks>
/// The terminal usage binds <c>SelectFilesHostCommand</c> with the selected tab as its parameter; the
/// SFTP usage binds <c>OpenTerminalForFilesHostCommand</c>, which needs none — see the remark on both in
/// <c>MainWindowViewModel</c> for why the two directions are not symmetrical.
/// </remarks>
internal static readonly StyledProperty<ICommand?> OpenCommandProperty =
AvaloniaProperty.Register<SessionHeader, ICommand?>(nameof(OpenCommand));
internal static readonly StyledProperty<object?> OpenCommandParameterProperty =
AvaloniaProperty.Register<SessionHeader, object?>(nameof(OpenCommandParameter));
/// <summary>What the header says instead of an address, while no session/host context is active.</summary>
internal static readonly StyledProperty<string?> EmptyTextProperty =
AvaloniaProperty.Register<SessionHeader, string?>(nameof(EmptyText));
public SessionHeader() => InitializeComponent();
internal string? OpenLabel
{
get => GetValue(OpenLabelProperty);
set => SetValue(OpenLabelProperty, value);
}
internal ICommand? OpenCommand
{
get => GetValue(OpenCommandProperty);
set => SetValue(OpenCommandProperty, value);
}
internal object? OpenCommandParameter
{
get => GetValue(OpenCommandParameterProperty);
set => SetValue(OpenCommandParameterProperty, value);
}
internal string? EmptyText
{
get => GetValue(EmptyTextProperty);
set => SetValue(EmptyTextProperty, value);
}
}
+137 -59
View File
@@ -19,70 +19,113 @@
Every row here is a command the shell already exposes for exactly this purpose — see
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
so this control carries no logic of its own beyond the list it draws and the click it forwards.
── v5c-4: THE SESSION BLOCK AT THE HEAD, AND THE HEADER ROW THAT IS GONE ─────────────────────────────────
The 60-pixel host header that used to sit above the pane on both surfaces has been retired, and its two
contents moved up here: the address it printed, and the cross-surface button — "Open SFTP" from a
terminal, "Open terminal" from SFTP. Which of the two words it is and which command it runs are resolved
by the shell now rather than handed in from the two usage sites; see
MainWindowViewModel.SessionCrossSurfaceLabel and OpenOtherSurfaceCommand. The pane keeps that height.
The address is the fact the header row existed for, so it moves rather than disappears. It sits where the
QUICK ACCESS heading used to print the selected tab's short label — that label said less than the address
does and would be the same word twice beside it.
── AND THE COLUMN CLOSES ────────────────────────────────────────────────────────────────────────────────
300 pixels is a lot of a 1180-pixel window to give a list that is often two rows long, so the column
folds to a 34-pixel rail carrying the way back. A rail rather than nothing: a panel that vanishes without
trace is one people report as lost. Both halves live in this control and swap on
MainWindowViewModel.IsSessionSidebarOpen, so MainWindow.axaml's own "Auto" column takes whichever width
is showing without knowing anything about the state — and the pane beside it grows into what is freed.
-->
<Border Width="300" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="6" Margin="16,20">
<Panel>
<!-- ============ QUICK ACCESS ============ -->
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
<TextBlock Grid.Column="0" Classes="label" Text="QUICK ACCESS" FontSize="10" />
<TextBlock Grid.Column="1" Classes="mono" FontSize="10"
Foreground="{StaticResource TextGhost}"
Text="{Binding SelectedTab.Label}" TextTrimming="CharacterEllipsis" MaxWidth="130" />
</Grid>
<!-- ============ THE RAIL, WHEN THE COLUMN IS CLOSED ============ -->
<!--
Painted and bordered like the open column so the closing reads as the same surface narrowing rather
than as one piece of furniture being swapped for another.
-->
<Border Width="34" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
IsVisible="{Binding !IsSessionSidebarOpen}">
<Button Classes="flat sidebargrip" VerticalAlignment="Top" Margin="0,20,0,0"
Command="{Binding ToggleSessionSidebarCommand}"
ToolTip.Tip="Show quick access, snips and the way across to the other surface">
<TextBlock Text="&#xE5CB;" FontFamily="{StaticResource IconFont}" FontSize="18"
Foreground="{StaticResource TextFaint}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</Border>
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!--
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up
from the path's string DataContext lands on it.
-->
<Button Classes="sidebarrow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE2C7;" FontFamily="{StaticResource IconFont}" FontSize="15"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- ============ THE COLUMN ============ -->
<Border Width="300" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
IsVisible="{Binding IsSessionSidebarOpen}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="6" Margin="16,20">
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- ============ THE SESSION ============ -->
<!--
The address, and the button that closes the column. Both on one row, and the address is the
trimming one: a long account@host:port is exactly the string that would otherwise push the
close button off the edge of a panel whose whole point is that it can be got rid of.
<!-- ============ SNIPS (the terminal surface only) ============ -->
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
SelectableTextBlock rather than TextBlock, which is what the rest of this application already
reaches for whenever a string is on screen to be taken somewhere else — a fingerprint, a public
key, a recovery code. This is the one place the shell prints the endpoint it actually dialled,
and the endpoint is a thing people paste: into another client, into a ticket, into the ssh
command they are writing beside this window. Retyping it off the screen is how a digit gets
dropped from an address that is mostly digits.
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
The trimming and the selection do disagree at the long end, and the ToolTip is why the
disagreement is tolerable rather than a trap: a drag reaches only the glyphs that were drawn,
so an address wearing an ellipsis has to be taken with select-all — Ctrl+A, then Ctrl+C — and
the hover already shows what the full string is, so nobody is copying half of one without
having been told there is a half.
-->
<Grid ColumnDefinitions="*,Auto" Margin="8,0,0,0">
<SelectableTextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
FontSize="12.5" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}" />
<Button Grid.Column="1" Classes="flat sidebargrip"
Command="{Binding ToggleSessionSidebarCommand}"
ToolTip.Tip="Close this column. The terminal takes the width, and the rail it leaves behind brings it back.">
<TextBlock Text="&#xE5CC;" FontFamily="{StaticResource IconFont}" FontSize="18"
Foreground="{StaticResource TextFaint}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible}">
<!--
The cross-surface button, stretched across the column rather than sized to its own caption: it
is the one action in this panel that is not a list row, and a 90-pixel button floating at the
left of a 300-pixel column would read as unfinished.
-->
<Button Classes="headerghost" HorizontalAlignment="Stretch" Margin="0,4,0,10"
Content="{Binding SessionCrossSurfaceLabel}"
Command="{Binding OpenOtherSurfaceCommand}" />
<!-- ============ QUICK ACCESS ============ -->
<TextBlock Classes="label" Text="QUICK ACCESS" FontSize="10" Margin="8,0" />
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<DataTemplate x:DataType="x:String">
<!--
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up
from the path's string DataContext lands on it.
-->
<Button Classes="sidebarrow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{Binding Snippet.Command}">
ToolTip.Tip="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
FontSize="11" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
<TextBlock Text="&#xE2C7;" FontFamily="{StaticResource IconFont}" FontSize="15"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
</Button>
@@ -90,19 +133,54 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}"
ToolTip.Tip="Opens the snippet editor.">
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<!-- ============ SNIPS (the terminal surface only) ============ -->
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
</StackPanel>
</ScrollViewer>
</Border>
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Button Classes="sidebarrow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{Binding Snippet.Command}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
FontSize="11" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}"
ToolTip.Tip="Opens the snippet editor.">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Panel>
</UserControl>
@@ -42,14 +42,29 @@
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
Classes.dropped="{Binding IsSessionDropped}"
VerticalAlignment="Center" />
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
IsVisible="{Binding IsSessionConnected}" />
<!--
Three words where there were two, and the third replaces rather than joins the second — see
MainWindowViewModel.IsSessionIdle, which is what keeps them from ever being drawn together.
NOT CONNECTED is honest about a tab still dialling and useless about one whose shell has
stopped: it says nothing about which of the two endings it was, and the difference between them
is the difference between work that is finished and a machine that stopped answering. The strip
below carries the sentence and the way back; this is the same fact in the one line that is
always on screen.
-->
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
IsVisible="{Binding !IsSessionConnected}" />
IsVisible="{Binding IsSessionIdle}" />
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="{Binding SessionEndedText}"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
@@ -60,12 +60,31 @@
ToolTip.Tip="{Binding Address}">
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
<!--
Two states, as the strip's own dots always were: green while the shell behind this tab is
running, grey while it is connecting and once it has ended. The design's third, amber,
state has no meaning here — nothing in this application checks whether a host is merely
reachable — so it is not drawn; see design-notes/v5b-fidelity-notes.md.
Three states now, where there were two. Green while the shell behind this tab is running
and grey once it has ended, as the strip's dots always were — and amber while it is
connecting, which used to be grey as well.
The design's amber had no meaning here while nothing in this application knew how far a
connection had got; that changed with the step list, and the note this comment used to
carry — that amber is for a host merely reachable, so it is not drawn — is answered
rather than ignored. It is not being reachable that is amber, it is being underway. See
ConnectingCard.axaml, whose track and running step are the same colour for the same
reason, and design-notes/v5b-fidelity-notes.md for the state this is not.
Worth the third colour because the two it replaces were the same one: a tab still
dialling and a tab whose shell has exited both drew grey, which are the two states in
this strip with the least in common — one is worth waiting for and the other is over.
── and a fourth since, splitting that grey ────────────────────────────────────────────
A shell that ended and a connection that broke were still one dot, and they are the two
dead states with the least in common: one is work finished, the other is a machine that
stopped answering and may well still be there. Red for the second, matching the strip
under the terminal — see TerminalTabViewModel.IsDropped and SessionEndedBar.axaml, which
is where the sentence and the way back live.
-->
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
Classes.connecting="{Binding IsConnecting}"
Classes.dropped="{Binding IsDropped}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
@@ -47,7 +47,7 @@
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Check on its own" />
<TextBlock Classes="settingsrowcaption"
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
Text="Every six hours while DodoSSH is running, starting at launch. It keeps checking while the keychain is locked — where builds come from has nothing to do with your vault — and while an update is already waiting, so what is offered is the newest build rather than the first one this run happened to see." />
</StackPanel>
<CheckBox Grid.Column="1" VerticalAlignment="Top"
IsChecked="{Binding Updates.IsAutomatic}"
@@ -15,8 +15,10 @@
TERMINAL keeps its one real setting, text size, and drops the design's other five — font, cursor,
scrollback, copy on select, terminal bell — which the renderer hard-codes. KEYCHAIN keeps Lock now,
Sync now/Sign in, and the importer row exactly as the old screen had them; the design's own SECURITY
card (clipboard-clear delay, confirm-run-on-insert) has nothing behind either row and is not drawn here.
Sync now/Sign in, and the importer rows; the design's own SECURITY card (clipboard-clear delay,
confirm-run-on-insert) has nothing behind either row and is not drawn here.
Two importer rows now rather than the one the old screen had: ~/.ssh/config, and a Hetzner Cloud project.
-->
<ScrollViewer>
@@ -95,7 +97,7 @@
</Grid>
</Border>
<Border Classes="settingsrow last">
<Border Classes="settingsrow">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Import SSH config" />
@@ -108,6 +110,24 @@
</Grid>
</Border>
<!--
The second importer, beside the first rather than folded into it: they read different things
through different code, and one row that asked "from where?" would put a question in front of
the far more common answer. See HetznerImportScreen.axaml.
-->
<Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Import from Hetzner Cloud" />
<TextBlock Classes="settingsrowcaption"
Text="Lists the servers in one Hetzner Cloud project and imports the ones you tick. Needs a read-only API token, which is used for a single request and never stored; a token belongs to one project, so an account with several runs this once each. No key can come with them — Hetzner has never held the private half of one — so imported hosts ask for a password until you bind them to a key in your keychain. Dedicated servers behind Hetzner's Robot are not covered." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN WIZARD"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.HetznerImport}" />
</Grid>
</Border>
</StackPanel>
</Border>
@@ -9,17 +9,20 @@
the same 53px bar: "Back to application" on the left, in place of the wordmark and the search box, and
the same three window-control glyphs on the right TitleBar.axaml already draws.
A separate control rather than a variant of TitleBar itself, on the same reasoning SessionHeader and
SessionStatusBar are their own files: nothing here can be measured by a test that hosts the real window,
A separate control rather than a variant of TitleBar itself, on the same reasoning SessionStatusBar and
SessionSidebar are their own files: nothing here can be measured by a test that hosts the real window,
and a control that is either "the wordmark bar" or "the settings bar" depending on a bound flag would be
two controls wearing one name. The dragging, maximising and closing logic is duplicated from TitleBar's
own code-behind rather than shared through a base class — four short handlers, and the day one of the two
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,47 @@
<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}"
SystemDecorations="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.
SystemDecorations="None" rather than BorderOnly, which the main window uses: this cannot be resized, has
no titlebar of its own to drag by, and lives for about a second. 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.");
}
}
@@ -25,14 +25,6 @@
channel this application has for saying that a save failed, that a sync was refused, that a merge picked
a winner. The design is a mock-up of a working afternoon and has nowhere for a sentence like that to go;
dropping the bar would have meant dropping the sentence, or repeating it on six screens.
── One line, several terminals ────────────────────────────────────────────────────────────────────────
One bar for a window that now holds any number of tabs, and connecting writes to it — so "Connected to
prod-db." outlived the tab it was about, and closing prod-db left that sentence sitting under whichever
terminal the user looked at next. A connection's own lines are now owned by the attempt that wrote them
and are cleared when its tab is closed; everything else the vault says stays, because closing a terminal
is not an answer to a failed save. See VaultViewModel.ForgetConnectionStatus and
MainWindowViewModel.CloseTabAsync.
-->
<Border Height="24" Background="{StaticResource Chrome}"
+10 -2
View File
@@ -79,8 +79,16 @@
-->
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
<!-- CTRL K, not the design's ⌘K — see the remark at the top of this file. -->
<Border Grid.Column="2" Width="34" Height="18" CornerRadius="5"
<!--
CTRL K, not the design's ⌘K — see the remark at the top of this file.
◆ PADDED RATHER THAN 34 WIDE, which is the design's own width for a chip reading ⌘K: two
glyphs, where the substitution this bar makes is six characters and a space. At 10.5 mono
that run is wider than 34, so the chip clipped it — "CTRL" with the K cut in half. MinWidth
keeps the design's footprint for the day this face has a ⌘ to draw, and the padding is what
the longer label actually needs.
-->
<Border Grid.Column="2" MinWidth="34" Height="18" CornerRadius="5" Padding="7,0"
Background="{StaticResource KbdChip}"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
@@ -33,6 +33,43 @@
layer has not got. See docs/design-import-gaps.md.
-->
<!--
══ THE TWO LISTINGS' COLUMNS ═════════════════════════════════════════════════════════════════════════
◆ They were fixed pixel widths, and fixed was wrong at both ends of the range this screen actually gets.
The numbers — 20/*/60/56 here and 20/*/56/52/48 across the arrow column — were chosen against the 204
pixels the session shell's own QUICK ACCESS sidebar leaves a pane at the window's minimum. Measured,
they were wrong in both directions at once:
· MODIFIED prints "2026-08-21 11:36", which needs 116 pixels, and never had more than 48. So the
column was truncated at every width this screen has ever been drawn at — a maximised window showed
"2026-08" beside a NAME column with four hundred empty pixels in it. PERMS needs 60 for a
ten-character mode and had 48; SIZE needs 58 for "999.9 GB" and had 56.
· At 204 the remote pane's NAME column measured **zero pixels wide**. Twenty for the glyph plus
56+52+48 for the numbers is 176 of the 180 a row has, so the file names — the one column a file
browser exists to show — were not drawn at all.
Star columns with a MaxWidth fix both, because the cap is what makes "share the width" mean the right
thing at each end. Wide, the three numeric columns stop growing at what their own text needs and NAME
keeps every remaining pixel. Narrow, nothing has a MinWidth to overflow with, so all four give ground
together and NAME — weighted heaviest — keeps enough to read a name by.
MODIFIED carries the extra weight (1.6) so that it reaches its cap first: it is the widest content and
the column somebody is most often actually reading. The local pane's NAME is 3* against the remote's 2*,
which is the remote's PERMS weight folded into it — that is what makes SIZE and MODIFIED come out the
same width on both sides of the arrow column, so the same amount of a timestamp is visible in each. See
Timestamps.Format for why both panes write one the same way in the first place; a shared format read at
two different widths would have made that argument for nothing.
◆ FOUR COPIES OF THIS, and they have to stay in step: a heading row and a row template per pane. Avalonia
cannot share one ColumnDefinitions instance between two Grids — each mutates its own computed sizes —
and SharedSizeGroup only applies to Auto columns. What catches a copy that drifts is measurement rather
than discipline: ScreenLayoutTests holds every cell against the width its own text needs
(TheListingColumnsShowTheirContentInFullWhenThereIsRoom), the headings against each other
(TheListingHeadingsDoNotRunIntoEachOther), and NAME against zero at the narrowest budget
(TheNameColumnSurvivesTheNarrowestBudget).
-->
<UserControl.Styles>
<!--
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -259,10 +296,18 @@
the row template below for why the whole column set was chosen against 204 pixels rather than the
design's own 1920-pixel canvas.
-->
<Grid Grid.Row="2" ColumnDefinitions="20,*,60,56" Margin="0,2,10,4">
<Grid Grid.Row="2" Margin="0,2,10,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="20" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" Margin="8,0,0,0"
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
</Grid>
<!--
@@ -274,14 +319,25 @@
SelectedItem="{Binding SelectedLocalEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
<Grid ColumnDefinitions="20,*,60,56" Height="38" Margin="14,0,10,0">
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
<Grid Height="38" Margin="6,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="24" />
<ColumnDefinition Width="20" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
</Grid.ColumnDefinitions>
<!-- The remote pane's own tick, on this side and for the same reasons. -->
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}"
Click="OnLocalRowChecked" />
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
@@ -327,10 +383,12 @@
<!-- ==== The host ==== -->
<!--
Six rows rather than five: the connection's own strip is the new one, and it is inside this pane
rather than above both because it is about this pane and nothing else.
Seven rows. Two of them are furniture over a listing rather than part of it: the connection's own
strip, and the row of things that can be done to whichever row is chosen. Both are inside this pane
rather than above both panes because both are about this one and nothing else. The two questions —
deleting, and renaming or moving — share a single row, since only one of the two is ever on screen.
-->
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*"
DragDrop.AllowDrop="True">
<!--
@@ -344,25 +402,48 @@
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
-->
<!--
The path is alone in the star column for the reason the local pane's header remark spells out:
TextTrimming needs the finite width only a star column gives it, and this is the pane where the
long path is not even unusual — it is any host with a deep directory tree.
◆ THE PATH IS TYPED HERE, and that is what the star column is now holding.
It was a TextBlock printing where the pane was. A breadcrumb only reaches what is under the
directory already listed, so crossing a host's filesystem was a walk to the common ancestor and
back down — a click and a round trip a level — and a path somebody already knew could not be
entered at all. Enter goes; Escape puts back what the pane is really showing. See
TransfersViewModel.RemotePathDraft for why the box holds its own value rather than RemotePath
itself, and SftpPath.Resolve for what an entry that is not absolute means.
Still alone in the star column, for the reason the local pane's header remark spells out: this box
has to be bounded, and an Auto column beside a StackPanel would measure it at its content's width
and push the buttons past the pane's edge.
DELETE has left this row. It acts on the selected row rather than on the pane, and it is one of
three that do now — see the item row below, which is where the three of them are together and
where there is room for three rather than for one.
-->
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"
Margin="0,0,12,0">
Margin="0,0,8,0">
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
IsVisible="{Binding ShowsBucketPicker}" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
HorizontalAlignment="Left"
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding RemotePath}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
<TextBox Grid.Column="1" Classes="pathbox" VerticalAlignment="Center"
Text="{Binding RemotePathDraft}" IsEnabled="{Binding IsConnected}"
PlaceholderText="path"
ToolTip.Tip="Type a path and press Enter. A relative path is taken from here; ~ is the account's home directory.">
<!--
One word, because the only time this placeholder shows is on a pane with nothing connected,
where the box is disabled and the invitation below is doing the explaining. "/path/to/
somewhere" needs 146 pixels and had 91 at the session shell's narrowest, so it drew as a
path cut off mid-segment. What the box is for is in the tooltip, where there is room for it.
-->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding GoToTypedRemotePathCommand}" />
<KeyBinding Gesture="Escape" Command="{Binding RevertTypedRemotePathCommand}" />
</TextBox.KeyBindings>
</TextBox>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5DB;" />
@@ -371,10 +452,6 @@
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D5;" />
</Button>
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE872;" />
</Button>
</StackPanel>
</Grid>
</Border>
@@ -382,8 +459,9 @@
<!--
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: SessionHeader now
prints the very same address above this whole screen — see MainWindowViewModel.SessionAddress,
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: the session shell
prints the very same address beside this screen — in the sidebar's own session block since v5c-4
retired the header row that printed it above — see MainWindowViewModel.SessionAddress,
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
@@ -401,9 +479,167 @@
</Border>
<!--
The question DELETE asks. Under the button rather than over the pane, so the row it is about is
still on screen and still selected while it is being answered — and it names the full path rather
than the file, because a name is the half that does not identify anything.
◆ WHAT CAN BE DONE TO THE ROW THAT IS CHOSEN. Only while something is connected.
Three buttons in one place, which is the point. DELETE used to sit alone in the pane header beside
UP and REFRESH — two things that move the pane and one that destroys a file, in one row — and
there was no rename and no move at all, so a file that had landed under the wrong name could only
be put right by deleting it and sending it again. These three act on the selection, so they belong
together and away from the two that act on the pane.
The name beside them is the row they are about, ellipsized. It says which row rather than which
path: the listing directly underneath has that row highlighted, so a full path here would be
repeating what the selection is already showing. The forms the buttons open do print the path,
because that is the moment something is committed rather than merely looked at.
The row goes while one of the three questions is up rather than sitting there greyed out, and so
does the breadcrumb below it — see ShowsRemoteItemActions. The form that opens is the answer to
what these buttons asked, and inside the session shell this pane is 204 pixels wide: the height
goes to the question or to the buttons that raised it, not to both.
-->
<Border Grid.Row="2" Padding="12,5" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1" IsVisible="{Binding ShowsRemoteItemActions}">
<Grid ColumnDefinitions="*,Auto">
<!--
The chosen row's name, or the count once anything is ticked. The count wins because it is the
one the buttons beside it are actually about: with three rows gathered up, a name would be
describing whichever of them happens to be highlighted while DELETE takes all three.
-->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="ghost" Content="ALL" Command="{Binding ToggleAllRemoteCommand}"
ToolTip.Tip="Tick every row, or untick them all" />
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
Text="{Binding SelectedRemoteName}"
IsVisible="{Binding !HasCheckedRemote}" />
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
Foreground="{StaticResource AccentText}"
Text="{Binding CheckedRemoteLabel}"
IsVisible="{Binding HasCheckedRemote}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
<!--
◆ FOUR NOW, and this is the one that was missing: the PERMS column has been a true fact
about a file with nothing to be done about it since it arrived, so the commonest next act
after using this screen was opening a terminal to chmod what had just been uploaded. Dark on
a bucket rather than live-and-refused — an object has an access policy rather than nine bits;
see CanChangeRemotePermissions.
A lock rather than a key glyph: a key is what this application's keychain uses, and one here
would read as "which credential" over a row that has nothing to do with one.
-->
<!--
◆ EDIT, and it is first because it is the only one of the five that changes nothing on the
host by pressing it: it fetches a copy and opens it. Dark on a head with no way to open a
file or watch one — see CanEditRemote, which is what the phone fails rather than this
button being absent there for a reason nobody can see.
-->
<Button Classes="paneicon" Command="{Binding EditRemoteCommand}"
IsEnabled="{Binding CanEditRemote}"
ToolTip.Tip="Open in this machine's editor, and send each save back">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE3C9;" />
</Button>
<Button Classes="paneicon" Command="{Binding ChangeRemotePermissionsCommand}"
IsEnabled="{Binding CanChangeRemotePermissions}"
ToolTip.Tip="Permissions on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE897;" />
</Button>
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE9AD;" />
</Button>
<Button Classes="paneicon" Command="{Binding MoveRemoteCommand}"
IsEnabled="{Binding CanMoveRemote}" ToolTip.Tip="Move to another directory">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE675;" />
</Button>
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE872;" />
</Button>
</StackPanel>
</Grid>
</Border>
<!--
◆ WHAT AN EDIT SESSION SAYS FOR ITSELF, and it is a strip rather than a question: nothing is
waiting on an answer, a file is simply open somewhere else and each save is going back. It shares
the questions' Grid row because it is exclusive with them — opening any of the three needs a
selection, and while one is up ShowsRemoteItemActions has taken the buttons away.
The log is newest-first and bounded. Watching means saving happens more than once, and each one is
a round trip somebody may want to see happened; an editor that autosaves would otherwise grow this
without limit. See TransfersViewModel.RemoteEditLog.
The conflict card inside it is the one state where a save is not written back. Three answers,
because those are the three that exist: send it anyway, throw away what was typed and take the
host's copy, or stop. There is deliberately no merge — this client has no way to do one, and a
button that pretended otherwise would be the fabrication this project's honesty rule forbids.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsEditingRemote}">
<!--
◆ THE WHOLE STRIP SCROLLS, not just the log inside it, and that is what makes this safe at any
width. It is the tallest thing that can be in this Grid row — a heading, a path, a log, a
conflict card and a button — and unlike the three questions beside it, it is not modal: the
listing underneath stays live, because a file being open in an editor is no reason to stop
being able to browse. So it cannot be allowed to take the pane's height, and bounding the
container rather than each part means nothing added inside it later can undo that.
134 is measured. Inside the session shell this pane is 204x377, and ScreenLayoutTests draws
this in its worst case — the conflict card open with a session's worth of saves behind it.
-->
<ScrollViewer MaxHeight="134" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding RemoteEdit.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
Text="{Binding RemoteEdit.RemotePath}" />
<!-- Bounded inside a bounded strip: this keeps a long session's log from pushing the conflict
card and STOP EDITING out of view within the scroller. -->
<ScrollViewer MaxHeight="48" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding RemoteEditLog}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Classes="mono" FontSize="11" Margin="0,1"
Foreground="{StaticResource TextGhost}" TextTrimming="CharacterEllipsis"
Text="{Binding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Border Padding="10,8" CornerRadius="8" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1"
IsVisible="{Binding HasRemoteEditConflict}">
<StackPanel Spacing="6">
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding RemoteEditConflict}" />
<WrapPanel>
<Button Classes="ghost" Content="UPLOAD ANYWAY" Margin="0,0,8,4"
Command="{Binding ForceSaveEditCommand}" />
<Button Classes="ghost" Content="RELOAD" Margin="0,0,0,4"
Command="{Binding ReloadEditCommand}" />
</WrapPanel>
</StackPanel>
</Border>
<Button Classes="ghost" Content="STOP EDITING" HorizontalAlignment="Left"
Command="{Binding StopEditingCommand}" />
</StackPanel>
</ScrollViewer>
</Border>
<!--
The question DELETE asks. It names the full path rather than the file, because a name is the half
that does not identify anything — and that is now the whole of how the question says which row it
is about: at the session shell's own 204-pixel width there is no height left for a listing under
it, so "the row is still on screen and still selected" is no longer true and the path in the card
is what carries the identity. See the rename form below, which is measured in the same shape.
This is the strongest warning on any of these screens, and deliberately: everything else this
application deletes is a tombstone against a copy the server still has, and a file on somebody's
@@ -414,15 +650,34 @@
CANCEL side by side want closer to 220, so at the narrow width they wrap onto their own line
instead of one of them going off the edge, which the WrapPanel achieves for free.
-->
<Border Grid.Row="2" Padding="12,10" Background="{StaticResource DangerWash}"
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsConfirmingRemoteDeletion}">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource Danger}"
Text="{Binding PendingRemoteDeletion.FullPath}" />
<!--
Every path, one per line, and never a count on its own: agreeing to "3 items" is agreeing to a
number. See RemoteDeletionRequest.Listing.
◆ 32, AND THE NUMBER IS MEASURED RATHER THAN CHOSEN. A ticked set has no ceiling, so this list
has to scroll, and the budget it scrolls inside is small: within the session shell this pane is
204 wide, where one remote path wraps to two lines, and the card also carries a heading, the
consequence and two buttons that wrap onto two rows at that width. 120 put the buttons outside
the window; 72 left CANCEL three pixels under it; 56 and 40 pushed the listing's own squeezed
row past the bottom — each caught by ScreenLayoutTests, which is why the five-path test exists
beside the single-path one rather than instead of it. 32 is one wrapped path, which is exactly
what the single-path question has always shown.
What that buys is a scrollbar rather than a taller card, and the heading carries the count so
the number is never the thing being scrolled for.
-->
<ScrollViewer MaxHeight="32" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource Danger}"
Text="{Binding PendingRemoteDeletion.Listing}" />
</ScrollViewer>
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Consequence}" />
<WrapPanel>
@@ -434,7 +689,149 @@
</StackPanel>
</Border>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
<!--
◆ RENAME AND MOVE, in the row the deletion question uses and never at the same time as it.
One form for both, because they differ only in what the box starts out holding and in the words
around it — see RemoteMoveRequest, where both sets are chosen. It shares this Grid row with
the deletion card above: the three questions are mutually exclusive by construction, each one's
own opening clearing the others, so a row of its own would be a row that is empty whenever this
one is not.
It is not painted in the deletion card's danger colours, and that is deliberate rather than an
oversight. A rename is undone by renaming back; the deletion beside it is the one thing this
application does that nothing can walk back, and spending the same red on both would make the red
stop meaning anything.
Enter commits from inside the box, which is where the hands already are once an answer has been
typed.
◆ IT TAKES THE WHOLE PANE at the session shell's own 204-pixel width, and that is measured rather
than hoped: the listing's star row is squeezed to nothing while either question is open, exactly
as the deletion card above already squeezed it. There is no room for a form and a listing in 336
pixels, and the form carries the full path precisely so the row underneath does not have to be
visible for the question to be about something identifiable. That is why the item row and the
breadcrumb step aside instead — the height they free is what keeps the form itself un-clipped,
which is what ScreenLayoutTests measures.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsMovingRemote}">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingRemoteMove.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
Text="{Binding PendingRemoteMove.FullPath}" />
<TextBox Text="{Binding RemoteMoveTarget}"
PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConfirmMoveRemoteCommand}" />
<KeyBinding Gesture="Escape" Command="{Binding CancelMoveRemoteCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="{Binding PendingRemoteMove.Note}" />
<WrapPanel>
<Button Classes="accent" Content="{Binding PendingRemoteMove.ConfirmLabel}" Margin="0,0,8,4"
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
Command="{Binding CancelMoveRemoteCommand}" />
</WrapPanel>
</StackPanel>
</Border>
<!--
◆ THE MODE, as nine boxes and the number they add up to.
In the same Grid row as the other two questions and never at the same time as either — each one's
own opening clears the others, which is what makes one row enough for all three.
Boxes rather than a field to type 755 into, and that is the design rather than a preference: the
mode is already drawn as -rwxr-xr-x in the column this form was opened from, so a who-can-do-what
grid is the same representation the reader is already holding. An octal field would ask them to
translate something they can see into something they cannot check. The octal is printed underneath
as it changes, so the two readings check each other rather than one replacing the other.
The EXEC column is headed from the request rather than fixed, because on a directory that bit is
permission to enter it — the commonest misreading of a POSIX mode there is, and one word fixes it.
See RemotePermissionsRequest.ExecuteLabel.
APPLY is dead until something has actually changed. A round trip that sets a mode to the mode it
already has is not wrong, only pointless, and a live button over an untouched form invites it.
Not painted in the deletion card's red. Changing a mode is undone by changing it back; the red is
spent on the one thing here that nothing walks back, and spending it twice would make it stop
meaning anything — the same argument the rename form above makes for itself.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsChangingRemotePermissions}">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingRemotePermissions.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
Text="{Binding PendingRemotePermissions.FullPath}" />
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,Auto,Auto,Auto" Margin="0,2">
<TextBlock Grid.Row="0" Grid.Column="1" Classes="hint" FontSize="10.5" Text="read"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="2" Classes="hint" FontSize="10.5" Text="write"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="3" Classes="hint" FontSize="10.5"
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="11.5" Text="owner"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="11.5" Text="group"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="11.5" Text="others"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
</Grid>
<SelectableTextBlock Classes="mono" FontSize="12.5"
Foreground="{StaticResource AccentText}"
Text="{Binding PendingRemotePermissions.Preview}" />
<WrapPanel>
<Button Classes="accent" Content="APPLY" Margin="0,0,8,4"
Command="{Binding ConfirmRemotePermissionsCommand}"
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
Command="{Binding CancelRemotePermissionsCommand}" />
</WrapPanel>
</StackPanel>
</Border>
<!--
Away while a question is up, for the reason the item row above states: the trail is where the pane
is and MKDIR makes a directory, and neither is what somebody answering a question about one row
is doing.
-->
<Grid Grid.Row="4" ColumnDefinitions="*,Auto" Margin="12,6,12,4"
IsVisible="{Binding !IsAskingAboutRemoteEntry}">
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -466,10 +863,16 @@
is showing, 140 plus MKDIR plus the spacing between them ran past the pane's own edge. Still wide
enough for a real directory name to be typed and read back, which is what this box is for; a
name that outgrows it scrolls inside the box the way every TextBox already does.
◆ The placeholder says "directory" rather than "new directory", which is what it said until it
was measured: the longer text needs 101 pixels inside a box that has 74, so it drew as "new
directo" — a placeholder cut off mid-word, which reads as a rendering fault rather than as a
hint. MKDIR is right beside it and carries the "new" half already. The path box in the header
above lost the same argument and for the same reason; see its own PlaceholderText.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
IsVisible="{Binding IsConnected}">
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="directory" />
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
</StackPanel>
</Grid>
@@ -479,28 +882,63 @@
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
-->
<Grid Grid.Row="4" ColumnDefinitions="20,*,56,52,48" Margin="0,2,10,4">
<Grid Grid.Row="5" Margin="0,2,10,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="20" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
<ColumnDefinition Width="*" MaxWidth="66" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" Margin="8,0,0,0"
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" Margin="6,0,0,0"
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
</Grid>
<ListBox Grid.Row="5" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
<ListBox Grid.Row="6" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
SelectedItem="{Binding SelectedRemoteEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<Grid ColumnDefinitions="20,*,56,52,48" Height="38" Margin="14,0,10,0">
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
<Grid Height="38" Margin="6,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="24" />
<ColumnDefinition Width="20" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
<ColumnDefinition Width="*" MaxWidth="66" />
</Grid.ColumnDefinitions>
<!--
◆ THE TICK IS NOT THE LIST'S SELECTION, and the two answer different questions. The
selection is "the row I am looking at", which is what rename, move and permissions act on
— each takes a form naming one path and is one-at-a-time by construction. This is "the
rows I have gathered up", which is what DOWNLOAD and DELETE act on, and it survives the
highlight moving.
It is drawn rather than left to ctrl-click for a reason beyond taste: the phone head shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a tap target
there whatever this pane does. One shape on both heads, and a state somebody can see.
Click is handled rather than bound one-way, because the count above the list is a property
of the collection rather than of any row — see TransfersViewModel.RemoteChecksChanged.
-->
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}"
Click="OnRemoteRowChecked" />
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Classes.exec="{Binding IsExecutable}"
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="5" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</Grid>
@@ -512,7 +950,7 @@
A directory with nothing in it. It no longer has to say anything about not being connected: that
state is the invitation below, which covers this pane whole.
-->
<TextBlock Grid.Row="5" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
<TextBlock Grid.Row="6" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
Text="Nothing in this directory." IsVisible="{Binding !HasRemoteEntries}" />
@@ -534,8 +972,14 @@
the picker arrives, so the tall shape is the form rather than form-plus-prose, and neither shape
reaches the floor. Anything added here has to hold that; the layout suite measures both.
-->
<Border Grid.Row="1" Grid.RowSpan="5" Background="{StaticResource Canvas}"
IsVisible="{Binding !IsConnected}">
<!--
ShowsRemoteInvitation rather than !IsConnected, which are no longer the same question. Pressing
DISCONNECT empties this pane, so an invitation over it covers nothing; a connection lost
underneath somebody leaves the listing exactly where it was, and covering that would hide the one
thing the strip below is offering to put them back into. See TransfersViewModel.ShowsRemoteContents.
-->
<Border Grid.Row="1" Grid.RowSpan="6" Background="{StaticResource Canvas}"
IsVisible="{Binding ShowsRemoteInvitation}">
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
HorizontalAlignment="Center" VerticalAlignment="Center">
@@ -678,11 +1122,11 @@
window that has stopped answering, and the answer arriving after the drop is the answer arriving
too late. See the local pane for why neither may hit-test.
-->
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
<Border Grid.Row="0" Grid.RowSpan="7" IsHitTestVisible="False"
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
<Border Grid.Row="0" Grid.RowSpan="7" IsHitTestVisible="False"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="12"
@@ -79,6 +79,20 @@ internal sealed partial class TransfersScreen : UserControl
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
/// <summary>
/// Tells the screen a row's tick moved, so the count and the buttons that read it keep up.
/// </summary>
/// <remarks>
/// The tick itself is two-way bound and needs nothing from here; what does is the count above the list,
/// which is a property of the collection rather than of any row. Subscribing per row would be a
/// subscription to attach and detach on every navigation, since each listing replaces every row it has.
/// See <c>TransfersViewModel.RemoteChecksChanged</c>.
/// </remarks>
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) => Transfers?.RemoteChecksChanged();
/// <inheritdoc cref="OnRemoteRowChecked" />
private void OnLocalRowChecked(object? sender, RoutedEventArgs e) => Transfers?.LocalChecksChanged();
private void OnLocalActivated(object? sender, TappedEventArgs e)
{
if (Transfers is { } transfers)
+14 -8
View File
@@ -72,6 +72,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Microsoft.TSS": {
"type": "Direct",
"requested": "[2.1.1, )",
"resolved": "2.1.1",
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
@@ -405,7 +411,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.storage": {
@@ -465,9 +471,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
@@ -567,11 +573,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -1,12 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Reading an OpenSSH client configuration and turning it into hosts this application can store.
Turning somewhere an estate is already described into hosts this application can store. Two sources:
an OpenSSH client configuration on this machine, and a Hetzner Cloud project over its API.
Its own project rather than a folder in DodoSSH.Client.Domain, which holds decrypted item shapes and
their codecs and has no package references at all. A parser, a resolver and a file-system walk are a
different concern with different dependencies, and keeping them apart is what lets the whole of the
parsing be tested with no Avalonia, no SQLite and no disk.
The Hetzner reader is here rather than in a project of its own because it is the same concern arriving
from somewhere else: a candidate list, warnings about what could not be represented, and a HostSecret at
the end of it. It brings no package reference with it — HttpClient and System.Text.Json are the shared
framework — and it stays testable on the same terms, against a stubbed HttpMessageHandler rather than
against strings.
-->
<ItemGroup>
@@ -0,0 +1,476 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Import;
/// <summary>
/// Hetzner Cloud answered, and the answer was not a list of servers.
/// </summary>
/// <remarks>
/// One exception type with a sentence already fit to show somebody, rather than a status code every caller
/// would have to translate for itself. The status is kept beside it because "401" and "503" are different
/// problems with different next steps — one is the token, the other is nothing the user can do — and the
/// screen distinguishes them by <see cref="IsTokenProblem"/> rather than by matching on prose.
/// </remarks>
public sealed class HetznerCloudException : Exception
{
/// <summary>Creates a failure that has no underlying exception — a refusal Hetzner spelled out.</summary>
public HetznerCloudException(HttpStatusCode? statusCode, string? code, string message)
: this(statusCode, code, message, innerException: null)
{
}
/// <summary>Creates a failure wrapping the one the transport or the parser threw.</summary>
public HetznerCloudException(
HttpStatusCode? statusCode,
string? code,
string message,
Exception? innerException)
: base(message, innerException)
{
StatusCode = statusCode;
Code = code;
}
/// <summary>What Hetzner returned, or null where the request never got an answer at all.</summary>
public HttpStatusCode? StatusCode { get; }
/// <summary>Hetzner's own machine-readable error code, where the body carried one.</summary>
public string? Code { get; }
/// <summary>Whether the token is the thing to go and fix.</summary>
/// <remarks>
/// The wizard's first step is where a token gets typed, so a failure that is about the token belongs
/// back on that step and a failure that is about Hetzner's weather does not — sending somebody back to
/// re-type a token that was fine, because a datacentre had a bad minute, teaches them the token is
/// wrong.
/// </remarks>
public bool IsTokenProblem =>
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
}
/// <summary>
/// Reads the servers in one Hetzner Cloud project.
/// </summary>
/// <remarks>
/// <para>
/// <b>One token is one project.</b> Hetzner issues Cloud API tokens per project and the API offers no way
/// to ask which project a token belongs to, or to list the others — so this reads exactly the project whose
/// token it was handed, and the screen says as much rather than implying it found everything the account
/// owns. Somebody with four projects runs this four times.
/// </para>
/// <para>
/// <b>Read is all it does.</b> Only <c>GET /v1/servers</c> is ever called, which is why the screen asks for
/// a read-only token: a Read &amp; Write token would work identically here and would be a credential capable
/// of deleting the estate, pasted into a text box for no gain.
/// </para>
/// <para>
/// <b>The token is never stored.</b> It lives in the view model's field for as long as the wizard is open
/// and goes no further — not into the vault, not into settings, not into a log. The next import asks again,
/// which is the same reasoning <c>ImportViewModel.ImportsKeys</c> gives for not remembering its tick.
/// </para>
/// <para>
/// <b>Dedicated servers are not here.</b> Hetzner's Robot — the dedicated-server and Storage Box side of the
/// business — is a different API behind different credentials, and nothing in this file touches it. An
/// account with both gets its cloud servers from this wizard and adds the rest by hand.
/// </para>
/// </remarks>
public sealed class HetznerCloudClient
{
/// <summary>The Cloud API, and the one address in this file.</summary>
public static readonly Uri CloudApi = new("https://api.hetzner.cloud/v1/", UriKind.Absolute);
/// <summary>What Hetzner will return per page, which is also its maximum.</summary>
private const int PerPage = 50;
/// <summary>
/// How many pages this will follow before giving up.
/// </summary>
/// <remarks>
/// A backstop against a paginator that never says "no more pages" rather than a limit anybody's estate
/// will meet: at fifty a page this is five thousand servers, and the warning it emits on the way out
/// says the list is short rather than letting it look complete.
/// </remarks>
private const int MaxPages = 100;
private readonly HttpClient http;
/// <summary>Creates a reader over its own transport, which is how the application builds one.</summary>
public HetznerCloudClient()
: this(new HttpClient { BaseAddress = CloudApi })
{
}
/// <summary>Creates a reader with its transport supplied, which is how the tests reach it.</summary>
/// <remarks>
/// The base address is set here when the caller left it unset, so a stubbed <c>HttpMessageHandler</c>
/// can be handed over on its own without every test repeating the one URL this class knows.
/// </remarks>
public HetznerCloudClient(HttpClient http)
{
ArgumentNullException.ThrowIfNull(http);
http.BaseAddress ??= CloudApi;
this.http = http;
}
/// <summary>
/// Reads every server the token's project holds.
/// </summary>
/// <remarks>
/// Pages are followed rather than assumed away: twenty-six servers in a project is enough to meet the
/// second page, and an importer that quietly showed the first fifty would offer a list that looks
/// complete and is not.
/// </remarks>
/// <exception cref="HetznerCloudException">
/// The token was refused, the project could not be read, or Hetzner could not be reached. Every failure
/// arrives as this, with a sentence already fit to put on the screen.
/// </exception>
public async Task<HetznerImport> ListServersAsync(string token, CancellationToken cancellationToken)
{
var bearer = Sanitise(token);
var servers = new List<HetznerServer>();
var warnings = new List<string>();
var page = 1;
while (page <= MaxPages)
{
var read = await ReadPageAsync(bearer, page, cancellationToken).ConfigureAwait(false);
foreach (var server in read.Servers ?? [])
{
servers.Add(Convert(server, warnings));
}
if (read.Meta?.Pagination?.NextPage is not { } next || next <= page)
{
return new HetznerImport(servers, warnings);
}
page = next;
}
var counted = servers.Count.ToString(CultureInfo.InvariantCulture);
warnings.Add(
$"Stopped after {MaxPages.ToString(CultureInfo.InvariantCulture)} pages. This list is the first "
+ counted + " servers in the project and not all of them.");
return new HetznerImport(servers, warnings);
}
/// <summary>
/// Trims the pasted token and refuses one that could not be a header value.
/// </summary>
/// <remarks>
/// A token copied out of the Hetzner console arrives with a newline behind it often enough to be worth
/// handling, and a header value carrying one throws a <see cref="FormatException"/> from inside
/// <see cref="HttpClient"/> — a stack trace where the answer is "we trimmed it for you". Anything left
/// after the trim that still cannot be a header is refused here, with a sentence, rather than there.
/// </remarks>
private static string Sanitise(string token)
{
var trimmed = token?.Trim() ?? string.Empty;
if (trimmed.Length == 0)
{
throw new HetznerCloudException(null, null, "Paste a Hetzner Cloud API token first.");
}
foreach (var character in trimmed)
{
if (char.IsWhiteSpace(character) || char.IsControl(character))
{
throw new HetznerCloudException(
null,
null,
"That does not look like a Hetzner Cloud API token — it has a space or a line break in "
+ "the middle of it. A token is one unbroken run of 64 letters and digits.");
}
}
return trimmed;
}
/// <summary>Fetches and parses one page, turning every way it can fail into one exception type.</summary>
private async Task<HetznerServersPage> ReadPageAsync(
string token,
int page,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"servers?page={page}&per_page={PerPage}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
HttpResponseMessage response;
try
{
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException failure)
{
throw new HetznerCloudException(
null,
null,
$"Could not reach {CloudApi.Host}: {failure.Message}",
failure);
}
catch (TaskCanceledException failure) when (!cancellationToken.IsCancellationRequested)
{
throw new HetznerCloudException(
null,
null,
$"{CloudApi.Host} did not answer in time.",
failure);
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
throw await FailureAsync(response, cancellationToken).ConfigureAwait(false);
}
try
{
return await response.Content
.ReadFromJsonAsync(HetznerJsonContext.Default.HetznerServersPage, cancellationToken)
.ConfigureAwait(false)
?? new HetznerServersPage(null, null);
}
catch (JsonException failure)
{
throw new HetznerCloudException(
response.StatusCode,
null,
$"{CloudApi.Host} answered with something that is not a server list. That usually means "
+ "a proxy answered instead of Hetzner.",
failure);
}
}
}
/// <summary>
/// Turns a refusal into the sentence the screen shows.
/// </summary>
/// <remarks>
/// Hetzner's own message is kept and something actionable is put in front of it, because "unable to
/// authenticate" on its own does not tell somebody where tokens come from — and the token step is
/// exactly where they will be standing when they read it.
/// </remarks>
private static async Task<HetznerCloudException> FailureAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
string? code = null;
string? detail = null;
try
{
var body = await response.Content
.ReadFromJsonAsync(HetznerJsonContext.Default.HetznerErrorEnvelope, cancellationToken)
.ConfigureAwait(false);
code = body?.Error?.Code;
detail = body?.Error?.Message;
}
catch (JsonException)
{
// Not Hetzner's error shape. The status still says what happened, which is what the switch below
// is written against — a proxy's HTML 502 must not lose the 502 while being parsed.
}
catch (NotSupportedException)
{
// A content type that is not JSON at all, for the same reason.
}
var said = detail is { Length: > 0 } ? $" Hetzner said: {detail}" : string.Empty;
var status = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture);
var summary = response.StatusCode switch
{
HttpStatusCode.Unauthorized =>
"Hetzner did not accept that token. Cloud API tokens are made per project, in the Hetzner "
+ "console under Security → API tokens, and the value is shown only once — so a token "
+ "that was written down half-copied fails exactly like this.",
HttpStatusCode.Forbidden =>
"That token is not allowed to read this project's servers. A read-only token is enough; a "
+ "token scoped to something narrower is not.",
HttpStatusCode.TooManyRequests =>
"Hetzner is rate-limiting this token. Give it a minute and start the scan again.",
HttpStatusCode.NotFound =>
$"{CloudApi.Host} has no server list at that address, which should not happen and probably "
+ "means something between here and Hetzner answered instead.",
_ when (int)response.StatusCode >= 500 =>
$"Hetzner Cloud returned {status}. That is a fault at their end rather than anything about "
+ "the token.",
_ => $"Hetzner Cloud returned {status}.",
};
return new HetznerCloudException(response.StatusCode, code, summary + said);
}
/// <summary>
/// Turns one server as the API describes it into one the import can offer.
/// </summary>
/// <remarks>
/// Everything the API leaves out is tolerated rather than fatal, and only the name is worth a warning: a
/// server with no plan or no image loses a phrase from a note, and a server with no name would arrive as
/// a bookmark called nothing.
/// </remarks>
private static HetznerServer Convert(HetznerServerDto server, List<string> warnings)
{
var name = server.Name;
if (string.IsNullOrWhiteSpace(name))
{
name = string.Create(CultureInfo.InvariantCulture, $"server-{server.Id}");
warnings.Add($"A server had no name in the project, so it is offered as {name}.");
}
var privateAddresses = (server.PrivateNet ?? [])
.Select(network => network.Ip)
.Where(ip => !string.IsNullOrWhiteSpace(ip))
.Select(ip => ip!)
.ToList();
var labels = (server.Labels ?? new Dictionary<string, string>(StringComparer.Ordinal))
.Select(label => label.Value is { Length: > 0 }
? $"{label.Key}={label.Value}"
: label.Key)
.Order(StringComparer.Ordinal)
.ToList();
return new HetznerServer(
server.Id,
name,
server.Status is { Length: > 0 } status ? status : "unknown",
Blank(server.PublicNet?.IPv4?.Ip),
Blank(server.PublicNet?.IPv6?.Ip),
privateAddresses,
Blank(server.ServerType?.Name),
Where(server.Datacenter),
Blank(server.Image?.Name),
labels);
}
/// <summary>Where a server runs, as a person would say it — or null where the API said nothing useful.</summary>
private static string? Where(HetznerDatacenterDto? datacenter)
{
if (datacenter is null)
{
return null;
}
var location = datacenter.Location;
var region = Blank(location?.Name) ?? Blank(datacenter.Name);
var place = (Blank(location?.City), Blank(location?.Country)) switch
{
({ } city, { } country) => $"{city}, {country}",
({ } city, null) => city,
(null, { } country) => country,
_ => null,
};
return (region, place) switch
{
({ } where, { } which) => $"{where} · {which}",
({ } where, null) => where,
(null, { } which) => which,
_ => null,
};
}
/// <summary>Folds an absent value and a present-but-empty one together, which JSON does not.</summary>
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>One page of <c>GET /v1/servers</c>.</summary>
internal sealed record HetznerServersPage(
[property: JsonPropertyName("servers")] IReadOnlyList<HetznerServerDto>? Servers,
[property: JsonPropertyName("meta")] HetznerMetaDto? Meta);
/// <summary>The envelope every Hetzner list response carries its paging in.</summary>
internal sealed record HetznerMetaDto(
[property: JsonPropertyName("pagination")] HetznerPaginationDto? Pagination);
/// <summary>Only the forward link is read; the rest of Hetzner's paging block is not needed to walk it.</summary>
internal sealed record HetznerPaginationDto(
[property: JsonPropertyName("next_page")] int? NextPage);
/// <summary>One server, as the API describes it. Every field is optional because the API's are.</summary>
internal sealed record HetznerServerDto(
[property: JsonPropertyName("id")] long Id,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("status")] string? Status,
[property: JsonPropertyName("public_net")] HetznerPublicNetDto? PublicNet,
[property: JsonPropertyName("private_net")] IReadOnlyList<HetznerPrivateNetDto>? PrivateNet,
[property: JsonPropertyName("server_type")] HetznerNamedDto? ServerType,
[property: JsonPropertyName("datacenter")] HetznerDatacenterDto? Datacenter,
[property: JsonPropertyName("image")] HetznerNamedDto? Image,
[property: JsonPropertyName("labels")] IReadOnlyDictionary<string, string>? Labels);
/// <summary>A server's public addresses. Either half can be absent on a server that has only the other.</summary>
internal sealed record HetznerPublicNetDto(
[property: JsonPropertyName("ipv4")] HetznerIpDto? IPv4,
[property: JsonPropertyName("ipv6")] HetznerIpDto? IPv6);
/// <summary>
/// One public address.
/// </summary>
/// <remarks>
/// The v6 case puts a <em>network</em> in this field rather than an address — see
/// <see cref="HetznerServer.FirstIPv6Address"/>, which is where that is dealt with rather than papered over
/// here.
/// </remarks>
internal sealed record HetznerIpDto([property: JsonPropertyName("ip")] string? Ip);
/// <summary>A server's attachment to one private network.</summary>
internal sealed record HetznerPrivateNetDto([property: JsonPropertyName("ip")] string? Ip);
/// <summary>The shape shared by the server type and the image, of which only the name is read.</summary>
internal sealed record HetznerNamedDto([property: JsonPropertyName("name")] string? Name);
/// <summary>Where a server runs.</summary>
internal sealed record HetznerDatacenterDto(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("location")] HetznerLocationDto? Location);
/// <inheritdoc cref="HetznerDatacenterDto" />
internal sealed record HetznerLocationDto(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("city")] string? City,
[property: JsonPropertyName("country")] string? Country);
/// <summary>How Hetzner wraps a refusal.</summary>
internal sealed record HetznerErrorEnvelope(
[property: JsonPropertyName("error")] HetznerErrorDto? Error);
/// <inheritdoc cref="HetznerErrorEnvelope" />
internal sealed record HetznerErrorDto(
[property: JsonPropertyName("code")] string? Code,
[property: JsonPropertyName("message")] string? Message);
/// <summary>
/// The serializer for everything above.
/// </summary>
/// <remarks>
/// Source-generated rather than reflective, for the reason every other JSON context in this repository is:
/// the Android head publishes trimmed, and a reflective deserializer in an assembly the phone links is a
/// trim warning at best and a runtime failure at worst.
/// </remarks>
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(HetznerServersPage))]
[JsonSerializable(typeof(HetznerErrorEnvelope))]
internal sealed partial class HetznerJsonContext : JsonSerializerContext;
+237
View File
@@ -0,0 +1,237 @@
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Import;
/// <summary>
/// Which of a Hetzner server's addresses an import should dial.
/// </summary>
/// <remarks>
/// One choice for the whole import rather than one per row, for the reason the ssh_config screen's key tick
/// is one control: an estate is reached the same way throughout — everything over the public internet, or
/// everything across the private network from a machine already inside it — and a per-server dropdown would
/// be forty answers to one question.
/// </remarks>
public enum HetznerAddressKind
{
/// <summary>The public IPv4 address, which is how all but the cheapest Hetzner servers are reached.</summary>
PublicIPv4 = 0,
/// <summary>The public IPv6 address, for an estate that is v6 throughout — or a server with no v4 at all.</summary>
PublicIPv6 = 1,
/// <summary>The address on the first private network the server is attached to.</summary>
Private = 2,
}
/// <summary>
/// An address an import settled on, and what it had to assume to get there.
/// </summary>
/// <param name="Value">What the host will dial.</param>
/// <param name="Warning">
/// What is not simply a fact the API stated, or null where it is. Every one of these is a place the import
/// is deciding something on somebody's behalf, and a bookmark that dials the wrong machine is worse than one
/// that was never offered — so they are shown beside the row rather than folded away.
/// </param>
public sealed record HetznerAddress(string Value, string? Warning);
/// <summary>
/// One server a Hetzner Cloud project holds, as the import sees it.
/// </summary>
/// <remarks>
/// <para>
/// Deliberately not a <see cref="HostSecret"/>, on exactly the reasoning <see cref="ImportedHost"/> gives:
/// this is a candidate nobody has agreed to import, and it carries facts a stored host has no field for —
/// the server's own id, what it is running, where it runs, and which of its several addresses were on offer.
/// </para>
/// <para>
/// <b>There is no key here and there cannot be.</b> The Cloud API will name the SSH keys a project holds and
/// hand over their public halves; the private halves have never been in Hetzner's hands. So an imported host
/// authenticates with a key that was already in this keychain or it asks for a password, and the import
/// screen says so rather than leaving somebody to find out at connect time.
/// </para>
/// </remarks>
/// <param name="Id">The server's id in the project, which is the one name of it that never changes.</param>
/// <param name="Name">What the project calls it, which is what the user will recognise.</param>
/// <param name="Status">
/// <c>running</c>, <c>off</c>, <c>initializing</c> and the rest. Shown on the row and deliberately not
/// stored: it stays true for as long as it takes somebody to press a button in the Hetzner console.
/// </param>
/// <param name="PublicIPv4">The public IPv4 address, or null on a server that has none.</param>
/// <param name="PublicIPv6Network">
/// The public IPv6 <em>network</em> — Hetzner routes a whole /64 and the API reports the prefix, not an
/// address. See <see cref="FirstIPv6Address"/> for what this import does with that.
/// </param>
/// <param name="PrivateAddresses">The addresses it has on private networks, in the order the API gave them.</param>
/// <param name="ServerType">The plan — <c>cx22</c>, <c>ccx13</c> — or null where the API did not say.</param>
/// <param name="Location">Where it runs, as a person would say it: <c>fsn1 · Falkenstein, DE</c>.</param>
/// <param name="Image">What it was built from, which is the best available hint at which user logs in.</param>
/// <param name="Labels">The project's own labels, as <c>key=value</c>, sorted.</param>
public sealed record HetznerServer(
long Id,
string Name,
string Status,
string? PublicIPv4,
string? PublicIPv6Network,
IReadOnlyList<string> PrivateAddresses,
string? ServerType,
string? Location,
string? Image,
IReadOnlyList<string> Labels)
{
/// <summary>Whether the API gave this server a public IPv4 address at all.</summary>
/// <remarks>
/// Not a formality. Hetzner bills IPv4 separately now, and a project built since then can be entirely
/// IPv6-only — which is exactly the case an import that assumed a v4 would get silently wrong.
/// </remarks>
public bool HasPublicIPv4 => PublicIPv4 is { Length: > 0 };
/// <summary>
/// The address inside the routed /64 that a stock Hetzner image actually answers on.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is a convention and not a fact the API stated</b>, which is why every use of it carries a
/// warning. What the API reports is <c>2a01:4f8:1c17:d00::/64</c> — a network, which nothing can dial.
/// The address Hetzner's own images configure is the first one in it, and the console shows the same
/// thing; a server whose operator moved it elsewhere in the /64 will not answer here.
/// </para>
/// <para>
/// Computed by setting the low byte rather than by string surgery on the prefix, so a network the API
/// spells some other way still yields an address rather than a malformed string.
/// </para>
/// </remarks>
public string? FirstIPv6Address
{
get
{
if (PublicIPv6Network is not { Length: > 0 } network)
{
return null;
}
var slash = network.IndexOf('/', StringComparison.Ordinal);
var prefix = slash < 0 ? network : network[..slash];
if (!IPAddress.TryParse(prefix, out var parsed)
|| parsed.AddressFamily != AddressFamily.InterNetworkV6)
{
return null;
}
var bytes = parsed.GetAddressBytes();
bytes[^1] = 1;
return new IPAddress(bytes).ToString();
}
}
/// <summary>
/// Settles on the address this server would be dialled at, or null where the choice has none to offer.
/// </summary>
/// <remarks>
/// The two public choices fall back to each other and the private one does not, and that asymmetry is
/// deliberate. Falling back between v4 and v6 reaches the same machine over the same internet, so the
/// worst case is a route somebody did not pick; falling back from a private address to a public one
/// would put an address on the open internet into a bookmark that was asked to stay off it. So a server
/// with no private address is simply not importable under that choice, and the screen says which.
/// </remarks>
public HetznerAddress? Resolve(HetznerAddressKind kind)
{
var six = FirstIPv6Address;
return kind switch
{
HetznerAddressKind.PublicIPv4 when HasPublicIPv4 => new(PublicIPv4!, null),
HetznerAddressKind.PublicIPv4 when six is not null => new(
six,
$"No public IPv4. This is the first address of {PublicIPv6Network}, which is where Hetzner's "
+ "own images put the server — the API reports the network, not an address."),
HetznerAddressKind.PublicIPv6 when six is not null => new(
six,
$"The API reports {PublicIPv6Network} rather than an address. This is the first address in "
+ "it, which is where Hetzner's own images put the server."),
HetznerAddressKind.PublicIPv6 when HasPublicIPv4 => new(
PublicIPv4!,
"No public IPv6 on this server, so this is its IPv4 address."),
HetznerAddressKind.Private when PrivateAddresses.Count > 1 => new(
PrivateAddresses[0],
$"On {PrivateAddresses.Count} private networks. This is the first the API listed."),
HetznerAddressKind.Private when PrivateAddresses.Count == 1 => new(PrivateAddresses[0], null),
_ => null,
};
}
/// <summary>Turns this into the host that would be stored.</summary>
/// <remarks>
/// <para>
/// <b>Nothing here authenticates.</b> There is no key to bring — see the remark on this record — so the
/// host arrives bound to nothing and asks for a password until somebody points it at a key already in
/// the keychain. That is the same shape an <c>ssh_config</c> import has with its key tick off, and it is
/// stated on the screen rather than discovered at connect time.
/// </para>
/// <para>
/// <b>What goes in the notes is what does not go stale.</b> The id, the plan, the location and the image
/// survive; the power state does not, so it is a column on the import screen and never a note. A note
/// saying "running" about a machine somebody switched off last March is worse than no note.
/// </para>
/// </remarks>
/// <param name="address">The address to dial, from <see cref="Resolve"/>.</param>
/// <param name="username">The login user, or null and empty for none.</param>
/// <param name="port">The SSH port.</param>
public HostSecret ToSecret(HetznerAddress address, string? username, int port)
{
ArgumentNullException.ThrowIfNull(address);
return new HostSecret
{
Label = Name,
Hostname = address.Value,
Port = port,
Username = string.IsNullOrWhiteSpace(username) ? null : username.Trim(),
Notes = Describe(address),
};
}
/// <summary>What the note says about where this came from, plus whatever the address had to assume.</summary>
private string Describe(HetznerAddress address)
{
var facts = new List<string> { string.Create(CultureInfo.InvariantCulture, $"server {Id}") };
if (ServerType is { Length: > 0 } plan)
{
facts.Add(plan);
}
if (Location is { Length: > 0 } where)
{
facts.Add(where);
}
if (Image is { Length: > 0 } image)
{
facts.Add($"image {image}");
}
if (Labels.Count > 0)
{
facts.Add($"labels {string.Join(", ", Labels)}");
}
var sentence = $"Imported from Hetzner Cloud: {string.Join(", ", facts)}.";
return address.Warning is { Length: > 0 } warning ? $"{sentence} {warning}" : sentence;
}
}
/// <summary>
/// Everything a Hetzner Cloud project yielded: the servers it can offer, and what it could not.
/// </summary>
/// <param name="Servers">The importable candidates, in the order the API returned them.</param>
/// <param name="Warnings">
/// What the read could not represent, at project level — a page that came back malformed, a server the API
/// described without a name. The counterpart of <see cref="SshConfigImport.Warnings"/>.
/// </param>
public sealed record HetznerImport(IReadOnlyList<HetznerServer> Servers, IReadOnlyList<string> Warnings);
@@ -55,6 +55,24 @@ internal sealed class S3FileStore : IRemoteFileStore
/// </remarks>
public bool IsConnected => Volatile.Read(ref disposed) == 0;
/// <inheritdoc />
/// <remarks>
/// Null forever, for the reason <see cref="IsConnected"/> is always true: there is no connection under
/// this to be lost. A request that fails reports itself, and nothing here outlives one.
/// </remarks>
public string? TransportFailure => null;
/// <inheritdoc />
/// <remarks>
/// Never raised; see <see cref="TransportFailure"/>. Declared rather than left off the type because the
/// screen holds whichever store it opened as an <c>IRemoteFileStore</c> and subscribes without asking
/// which kind it got — an event that is simply never raised is what makes that honest rather than a
/// cast and a branch.
/// </remarks>
#pragma warning disable CS0067 // The event is never used
public event EventHandler? ConnectionLost;
#pragma warning restore CS0067
/// <inheritdoc />
public string HomeDirectory => ObjectKeys.Root;
@@ -383,6 +401,32 @@ internal sealed class S3FileStore : IRemoteFileStore
/// a rename's clothing, and the failure mode is a half-moved directory.
/// </para>
/// </remarks>
/// <inheritdoc />
/// <remarks>
/// Refused rather than approximated. An object is governed by an ACL and a bucket policy, which are not
/// nine bits and do not map onto them — writing something plausible here would put a mode in a listing
/// that no request would ever be decided by.
/// </remarks>
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken) =>
throw new SftpPathException(
path,
"An object in a bucket has an access policy rather than a POSIX mode, so there is nothing here to set.");
/// <inheritdoc />
/// <remarks>
/// Refused for the reason above: an object's timestamp is when the service stored it, and there is no
/// request that says otherwise. A copy that claimed the source's time would be a date the bucket does
/// not agree with.
/// </remarks>
public Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken) =>
throw new SftpPathException(
path,
"An object's timestamp is when the bucket stored it, and cannot be set to anything else.");
/// <inheritdoc />
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fromPath);
@@ -55,14 +55,14 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"libsodium": {
"type": "CentralTransitive",
@@ -81,11 +81,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
+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
+23 -1
View File
@@ -23,9 +23,13 @@ namespace DodoSSH.Client.Session;
/// <param name="AutomaticUpdateChecks">
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
/// </param>
/// <param name="SessionSidebarOpen">
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
/// </param>
public sealed record ClientSettings(
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
bool AutomaticUpdateChecks = true)
bool AutomaticUpdateChecks = true,
bool SessionSidebarOpen = true)
{
/*
A positional record, and the defaults live on the parameters rather than on property initializers.
@@ -102,6 +106,24 @@ public sealed record ClientSettings(
warns against.
*/
/*
SessionSidebarOpen: why closing the sidebar is remembered, and why it is remembered here.
On by default, because the sidebar is where a session's pins, its snips and the way across to the
other surface live — a first launch that hid all three would be hiding the feature rather than
offering to.
Remembered at all because closing it is a choice about how much of a 1180-pixel window a terminal
gets, and a choice that has to be made again on every launch is one the application is not really
offering. It belongs in this file rather than in the vault for the same reason the font size does:
it is a fact about this screen, not about this keychain, and following somebody from a 27-inch
monitor onto a laptop would be a preference nobody asked for.
Not per-surface and not per-tab. The sidebar is one control drawn on two screens — see
SessionSidebar.axaml — and a window where it is open on SFTP and closed on the terminal is a
window that appears to lose it at random.
*/
/// <summary>Brings a value inside the range this type will store.</summary>
public static int ClampTerminalFontSize(int pixels) =>
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
@@ -148,7 +148,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.storage": {
@@ -187,9 +187,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
@@ -289,11 +289,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -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.
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,129 @@
using CommunityToolkit.Mvvm.ComponentModel;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// One named part of making a connection, in the order they happen.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="SshConnectionPhase"/> with one more at the front. The SSH assembly reports four phases and
/// knows about no others, which is correct for it — it has never heard of a renderer. But the first thing a
/// connection here waits on is the terminal page attaching its socket, and on the first connection after a
/// cold start that is a real wait with a real failure mode of its own: a missing WebView2 runtime. A step
/// list that began at "reaching the host" would leave the one wait most likely to hang unnamed.
/// </para>
/// <para>
/// Declared here rather than shared with the SSH layer for that reason, and the mapping between the two is
/// one <c>switch</c> in <c>VaultViewModel</c>. The numbering is the order and the order is load-bearing:
/// <see cref="TerminalTabViewModel.Advance"/> compares these values to decide what is already behind it.
/// </para>
/// </remarks>
internal enum ConnectionStep
{
/// <summary>Waiting for the renderer to attach, before anything is dialled.</summary>
PreparingTerminal = 0,
/// <inheritdoc cref="SshConnectionPhase.Reaching" />
Reaching = 1,
/// <inheritdoc cref="SshConnectionPhase.CheckingHostKey" />
CheckingHostKey = 2,
/// <inheritdoc cref="SshConnectionPhase.Authenticating" />
Authenticating = 3,
/// <inheritdoc cref="SshConnectionPhase.OpeningShell" />
OpeningShell = 4,
}
/// <summary>How one step of a connection is getting on.</summary>
/// <remarks>
/// Four states rather than a bool per row, because a step list is read as a sequence and the reader's
/// question at each row is which of the four this is: behind us, happening, not yet, or where it stopped.
/// <see cref="Stopped"/> exists only for the row a failure landed on — see
/// <see cref="TerminalTabViewModel.Failed"/> — and is what turns the list from a progress bar into an
/// account of how far the attempt got.
/// </remarks>
internal enum ConnectionStepState
{
/// <summary>Not started. Nothing is known about it yet.</summary>
Pending = 0,
/// <summary>Happening now.</summary>
Running = 1,
/// <summary>Finished, because something after it started.</summary>
Done = 2,
/// <summary>Where the attempt stopped. There is no step after this one.</summary>
Stopped = 3,
}
/// <summary>One row of the connecting card's step list.</summary>
/// <remarks>
/// A view model per step rather than an index the view compares against, because each row draws its own
/// state and an <c>ItemsControl</c> has no way to ask "am I before the current one?" — the alternative was a
/// converter taking two bindings, which is the same comparison written somewhere it cannot be tested.
/// </remarks>
internal sealed partial class ConnectionStepViewModel : ObservableObject
{
internal ConnectionStepViewModel(ConnectionStep step, string caption)
{
Step = step;
Caption = caption;
}
/// <summary>Which step this is.</summary>
internal ConnectionStep Step { get; }
/// <summary>What the row says, in the present tense of the thing being waited on.</summary>
internal string Caption { get; }
/// <inheritdoc cref="ConnectionStepState" />
[ObservableProperty]
private ConnectionStepState state;
/// <summary>Whether this step is the one happening now.</summary>
internal bool IsRunning => State is ConnectionStepState.Running;
/// <summary>Whether this step finished.</summary>
internal bool IsDone => State is ConnectionStepState.Done;
/// <summary>Whether the attempt stopped on this step.</summary>
internal bool IsStopped => State is ConnectionStepState.Stopped;
/// <summary>
/// The character drawn beside the caption for whichever state this is in.
/// </summary>
/// <remarks>
/// Here rather than in a converter for the reason <c>TransferRowViewModel.StatusWord</c> is: the mapping
/// is four cases with no arithmetic, and a converter would put it in a file the shell's tests cannot
/// reach. The colours stay in the view, where the palette is.
/// <para>
/// Four distinguishable shapes rather than one recoloured, because the difference between a step that
/// finished and a step still running has to survive somebody who cannot tell this design's green from
/// its amber.
/// </para>
/// </remarks>
internal string Mark => State switch
{
ConnectionStepState.Done => "✓",
ConnectionStepState.Running => "●",
ConnectionStepState.Stopped => "✕",
_ => "○",
};
partial void OnStateChanged(ConnectionStepState value)
{
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsStopped));
OnPropertyChanged(nameof(Mark));
}
}
/// <summary>
/// How far along a tab's connection is.
/// </summary>
@@ -57,8 +179,23 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
{
Label = label;
Address = address;
status = "connecting…";
isLive = false;
Steps =
[
new ConnectionStepViewModel(ConnectionStep.PreparingTerminal, "Starting the terminal"),
new ConnectionStepViewModel(ConnectionStep.Reaching, "Reaching the host"),
new ConnectionStepViewModel(ConnectionStep.CheckingHostKey, "Checking the host key"),
new ConnectionStepViewModel(ConnectionStep.Authenticating, "Signing in"),
new ConnectionStepViewModel(ConnectionStep.OpeningShell, "Opening the shell"),
];
// The first step is running before anything is awaited, because it is: the tab is created in the
// same turn as the click and the renderer wait starts immediately after. A list that opened with
// every row pending would show a connection that had not begun, which is one turn of the dispatcher
// away from being untrue and is the turn the card is first drawn in.
status = Steps[0].Caption;
Steps[0].State = ConnectionStepState.Running;
}
/// <summary>A tab for a session that is already open.</summary>
@@ -72,6 +209,12 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
state = TerminalTabState.Open;
status = string.Empty;
isLive = true;
// A session that already exists got through every step by definition, even though this tab watched
// none of them happen — an adopted session is one whose connecting tab the user closed. The list is
// never drawn for a tab in this state; it is filled in so that nothing downstream has to treat "open"
// as a fourth answer to "how far did it get".
CompleteSteps();
}
/// <summary>
@@ -84,18 +227,6 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// </remarks>
internal uint SessionId { get; private set; }
/// <summary>
/// The connection attempt this tab was opened for, or <see cref="Guid.Empty"/> for a tab nothing dialled.
/// </summary>
/// <remarks>
/// Carried only so that closing this tab can take the vault's status line with it when the line is about
/// this connection — see <c>VaultViewModel.ForgetConnectionStatus</c>. It is the attempt id and not the
/// session id because the message can be written before a session exists ("Connecting to prod-db…") and
/// after one has stopped existing, and because the two failure sentences a tab can end on never have a
/// session id at all.
/// </remarks>
internal Guid AttemptId { get; init; }
internal string Label { get; }
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
@@ -140,6 +271,36 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// </remarks>
internal string? IdentityLabel { get; set; }
/// <summary>
/// How far this connection got, step by step, for the card that stands in for the pane.
/// </summary>
/// <remarks>
/// <para>
/// Fixed at construction and never added to or removed from — the steps of a connection are known before
/// it starts, and only their state changes — so a plain array is enough and the view needs no collection
/// change notification for it.
/// </para>
/// <para>
/// <b>Every row here is reported, not guessed.</b> The states come from
/// <see cref="SshConnectionPhase"/>, raised by the handshake itself at the moment each part of it begins.
/// Nothing on this list is a timer, a fraction, or a step this view model decided had probably finished
/// by now. That is the whole reason it is worth showing: a card that invented plausible progress would be
/// indistinguishable from one that had stopped receiving any.
/// </para>
/// </remarks>
internal IReadOnlyList<ConnectionStepViewModel> Steps { get; }
/// <summary>How many steps are behind the attempt, for the card's track.</summary>
/// <remarks>
/// Counted rather than stored, and it counts <see cref="ConnectionStepState.Done"/> alone: the running
/// step is deliberately not half a step. The track fills to where the attempt has actually got to and
/// stops there, which is the same promise the list itself makes.
/// </remarks>
internal int StepsDone => Steps.Count(step => step.IsDone);
/// <summary>How many steps there are, for the card's track.</summary>
internal int StepCount => Steps.Count;
/// <inheritdoc cref="TerminalTabState" />
[ObservableProperty]
private TerminalTabState state;
@@ -190,6 +351,95 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
[ObservableProperty]
private bool isShowing;
/// <summary>
/// How the shell behind this tab ended, or null while it has not.
/// </summary>
/// <remarks>
/// Null covers three states that need no telling apart here — still connecting, still running, and a
/// connection that never happened — because the only thing anything asks this is which ending to word,
/// and none of the three has one.
/// </remarks>
internal SshSessionEnd? EndReason { get; private set; }
/// <summary>
/// The sentence the shell's end came with, or null while there is none.
/// </summary>
/// <remarks>
/// The same string the renderer wrote into the pane, so the detail under a status line and the line in
/// the scrollback are one sentence rather than two accounts that can drift.
/// </remarks>
internal string? EndDescription { get; private set; }
/// <summary>Whether this tab has a session that has stopped, and something to say about it.</summary>
internal bool HasEnded => State is TerminalTabState.Open && !IsLive;
/// <summary>
/// The sentence the strip under a stopped terminal prints: why a reconnect was refused if one just was,
/// and otherwise how the session ended.
/// </summary>
/// <remarks>
/// <para>
/// The refusal wins because it is the newer answer and it is about something the user just did. A tab
/// whose reconnect was turned down — the machine has left the keychain, the key it was bound to is gone —
/// would otherwise go on printing how the shell ended, which is true and is not what was just asked.
/// </para>
/// <para>
/// <see cref="Status"/> is where the refusal lands because that is already what this property means:
/// what the tab has to say for itself. It is empty for the whole of a session's life —
/// <see cref="Opened"/> clears it — so a non-empty one on a stopped tab can only be something that
/// happened after the shell did.
/// </para>
/// </remarks>
internal string? EndedDetail => Status is { Length: > 0 } spoken ? spoken : EndDescription;
/// <summary>Whether this tab's session ended because the connection broke rather than because the shell did.</summary>
/// <remarks>
/// The tab strip's dot binds this, so the two endings are told apart at a glance in the one place every
/// terminal is listed. They used to draw the same grey, which made a tab that had been cut off
/// indistinguishable from one somebody had closed down properly.
/// </remarks>
internal bool IsDropped => EndReason is SshSessionEnd.ConnectionLost;
/// <summary>
/// What the status bar says instead of CONNECTED, or null while nothing has ended.
/// </summary>
/// <remarks>
/// <para>
/// Two words rather than one, because the two endings are read completely differently. A shell that
/// exited is the ordinary end of a day's work and wants no alarm; a connection that broke is a machine
/// that may well still be there, and is the whole reason the reconnect beside it exists.
/// </para>
/// <para>
/// <see cref="SshSessionEnd.ClosedLocally"/> has no word here on purpose. This client closed the session,
/// which means a tab is being removed or reconnected — there is no moment at which somebody reads a
/// status line about it.
/// </para>
/// </remarks>
internal string? EndedSummary => EndReason switch
{
SshSessionEnd.ConnectionLost => "DISCONNECTED",
SshSessionEnd.RemoteClosed => "SHELL EXITED",
_ => null,
};
/// <summary>
/// Whether this tab is in a state where dialling again would mean something.
/// </summary>
/// <remarks>
/// <para>
/// True for a shell that ended however it ended, and for a connection that was refused. All three are a
/// tab with no live session and a machine it was pointed at — and offering the retry on a refusal is
/// worth as much as offering it on a drop, because the commonest refusal is a host that was not up yet.
/// </para>
/// <para>
/// <b>It says the tab is willing, not that the shell can do it.</b> Whether there is anything to dial —
/// a keychain entry under this tab's name, and one that does not need a password typed first — is the
/// shell's question, because this view model has never heard of a vault. See
/// <c>MainWindowViewModel.CanReconnectSelectedTab</c>.
/// </para>
/// </remarks>
internal bool CanOfferReconnect => HasEnded || IsFailed;
/// <summary>Whether there is a pane behind this tab.</summary>
internal bool HasSession => State is TerminalTabState.Open;
@@ -199,13 +449,70 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// <summary>Whether this tab is a connection that never happened.</summary>
internal bool IsFailed => State is TerminalTabState.Failed;
/// <summary>
/// Records that the connection has reached a named step.
/// </summary>
/// <remarks>
/// <para>
/// Everything before <paramref name="step"/> is marked done, because a phase that has begun is proof the
/// ones before it ended — the handshake is a sequence and there is no way to be at one point in it
/// without having passed the earlier ones. That is also what covers a step too fast to observe: it is
/// closed by its successor rather than needing a report of its own.
/// </para>
/// <para>
/// Monotonic, and silently so. A report that has already been passed is ignored rather than rewinding
/// the list, because the one thing that can produce one is a retry after the host-key question, and a
/// card that jumped backwards would read as the connection having come undone.
/// </para>
/// </remarks>
internal void Advance(ConnectionStep step)
{
if (State is not TerminalTabState.Connecting)
{
// Nothing to draw and nothing to correct. A late report from a handshake that has since
// finished or been given up on is not worth reopening a settled tab for.
return;
}
var reached = Steps.FirstOrDefault(row => row.Step == step);
if (reached is null || reached.IsDone)
{
return;
}
foreach (var row in Steps)
{
if (row.Step < step)
{
row.State = ConnectionStepState.Done;
}
else if (row.Step == step)
{
row.State = ConnectionStepState.Running;
}
}
Status = reached.Caption;
OnPropertyChanged(nameof(StepsDone));
}
/// <summary>Takes ownership of the session that has just opened for this tab.</summary>
internal void Opened(uint sessionId)
{
SessionId = sessionId;
Status = string.Empty;
// Cleared before the flags below raise off them. A tab reconnecting carries one of these from the
// session that just ended, and a live terminal that still says how the last one stopped is the
// stale-fact failure this pair exists to remove.
EndReason = null;
EndDescription = null;
IsLive = true;
State = TerminalTabState.Open;
CompleteSteps();
}
/// <summary>
@@ -220,13 +527,152 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
{
Status = reason;
IsLive = false;
// A refusal is not an ending of the kind Ended records — there was never a session to end — so
// anything a previous attempt on this tab left behind goes with it. Without this, a reconnect that
// is itself refused would draw the refusal and the old shell's ending at once.
EndReason = null;
EndDescription = null;
// Before the state change, so the list is already correct the first time a view asks. The step that
// was running is where it stopped, and the ones behind it stay done: how far a refused connection
// got is the most useful thing the card still knows, and it is the difference between "that host is
// not there" and "that host is there and would not have me".
foreach (var row in Steps)
{
if (row.IsRunning)
{
row.State = ConnectionStepState.Stopped;
}
}
State = TerminalTabState.Failed;
}
/// <summary>
/// Records that the shell behind this tab has stopped, and how.
/// </summary>
/// <param name="end">Which of the three ways it ended.</param>
/// <param name="description">The sentence the pump wrote into the pane, for the detail line.</param>
/// <remarks>
/// <para>
/// The tab stays and its pane stays, exactly as before: the last thing the remote said is usually why the
/// shell ended, and the renderer has already written this same sentence into the scrollback. What is new
/// is that the tab now knows <em>which</em> ending it was, which is the difference between a shell that is
/// finished and a machine that is probably still there — see <see cref="CanOfferReconnect"/>.
/// </para>
/// <para>
/// Ignored for a tab that is connecting or failed. A session end can only be about a session, and a late
/// announcement for one this tab has already been reconnected past would otherwise reach back and mark a
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
/// </para>
/// </remarks>
internal void Ended(SshSessionEnd end, string description)
{
if (State is not TerminalTabState.Open)
{
return;
}
EndReason = end;
EndDescription = description;
IsLive = false;
OnPropertyChanged(nameof(EndedDetail));
OnPropertyChanged(nameof(EndedSummary));
OnPropertyChanged(nameof(EndDescription));
OnPropertyChanged(nameof(IsDropped));
}
/// <summary>
/// Puts this tab back to connecting, for an attempt that is about to be made on the same machine.
/// </summary>
/// <remarks>
/// <para>
/// The tab is reused rather than a second one opened beside it, because reconnecting is what a person
/// means when they say it: the tab they were working in comes back, in the place it was, still selected.
/// A new tab would leave the strip holding two entries for one machine, one of them a headstone.
/// </para>
/// <para>
/// <b>The session id is dropped here, and the pane it named is the caller's to reclaim.</b> Nothing in
/// this view model can send a frame; what it can do is stop claiming a session that is about to stop
/// being this tab's. See <c>MainWindowViewModel.ReconnectTabAsync</c>, which closes the old session for
/// its pane's sake — a pane holds an xterm, its scrollback and a WebGL context, and browsers hand out
/// about sixteen of the last.
/// </para>
/// <para>
/// The step list is rebuilt to how it looked at the start of the first attempt rather than left as it
/// was. A list still showing where the last attempt got to would read as progress this one had already
/// made, which is the one thing the card promises never to do.
/// </para>
/// </remarks>
internal void Reconnecting()
{
SessionId = 0;
EndReason = null;
EndDescription = null;
IsLive = false;
// Cleared with the session they were facts about. Keeping them would put the previous connection's
// cipher in the status bar beside a handshake that has not negotiated one yet.
StartedAt = null;
Cipher = null;
HostKeyAlgorithm = null;
IdentityLabel = null;
foreach (var row in Steps)
{
row.State = ConnectionStepState.Pending;
}
// The same opening position the constructor sets, and for the same reason: the renderer wait starts
// in this turn, so a list of five pending rows would show an attempt that had not begun.
Steps[0].State = ConnectionStepState.Running;
Status = Steps[0].Caption;
State = TerminalTabState.Connecting;
OnPropertyChanged(nameof(StepsDone));
OnPropertyChanged(nameof(EndedDetail));
OnPropertyChanged(nameof(EndedSummary));
OnPropertyChanged(nameof(EndDescription));
OnPropertyChanged(nameof(IsDropped));
}
/// <summary>Marks every step done, for a connection that is no longer being waited on.</summary>
private void CompleteSteps()
{
foreach (var row in Steps)
{
row.State = ConnectionStepState.Done;
}
OnPropertyChanged(nameof(StepsDone));
}
partial void OnStateChanged(TerminalTabState value)
{
OnPropertyChanged(nameof(HasSession));
OnPropertyChanged(nameof(IsConnecting));
OnPropertyChanged(nameof(IsFailed));
OnPropertyChanged(nameof(HasEnded));
OnPropertyChanged(nameof(CanOfferReconnect));
// Opened and Failed both clear the previous ending and then land here, so this is where a dot that
// was red for the last session goes back to whatever the new state deserves.
OnPropertyChanged(nameof(IsDropped));
}
/// <remarks>
/// <see cref="HasEnded"/> and <see cref="CanOfferReconnect"/> are both partly this flag, and the strip
/// binds them — so a shell that stops has to move them in the same notification that greys its dot.
/// </remarks>
/// <remarks>The strip under a stopped terminal reads this as well as the ending; see <see cref="EndedDetail"/>.</remarks>
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(EndedDetail));
partial void OnIsLiveChanged(bool value)
{
OnPropertyChanged(nameof(HasEnded));
OnPropertyChanged(nameof(CanOfferReconnect));
}
}
File diff suppressed because it is too large. Load diff
@@ -74,15 +74,21 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
/// </remarks>
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
/// <summary>How long to wait before the first pass.</summary>
/// <summary>How long the look taken on the way to a restart may take before it is given up on.</summary>
/// <remarks>
/// A delay, where <c>VaultViewModel</c>'s sync loop runs a pass immediately. The difference is what the
/// user is waiting for: a vault edited on another machine should be current by the time they have
/// finished reading the list, whereas nothing anybody does in their first two minutes depends on an
/// update. Launch is already contending for the network and the CPU with a schema migration, a resumed
/// sign-in and a first sync, at the one moment somebody is watching the window.
/// <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 FirstCheckDelay = TimeSpan.FromMinutes(2);
private static readonly TimeSpan LastLookTimeout = TimeSpan.FromSeconds(10);
private readonly IUpdateChannel updates;
private readonly ClientSettingsStore settings;
@@ -153,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;
@@ -165,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>
@@ -211,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));
@@ -242,16 +299,40 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
loop = RunCheckLoopAsync(lifetime.Token);
}
/// <remarks>
/// <para>
/// <b>The first pass runs at launch, with no delay in front of it.</b> It used to wait two minutes, on
/// the argument that nothing anybody does in their first two minutes depends on an update and launch is
/// already contending for the network with a schema migration, a resumed sign-in and a first sync. What
/// that argument leaves out is the run that is over before the two minutes are: a client opened to reach
/// one host and closed again never checks at all, and a machine used that way is exactly the one ADR
/// 0011 warns about — quietly a year behind, with the mechanism to fix it switched on and never reached.
/// Every start now asks.
/// </para>
/// <para>
/// <b>The yield is what keeps that off the launch path.</b> <see cref="Start"/> is called from
/// <c>MainWindowViewModel.StartAsync</c> before the migration, so running the pass inline would put
/// whatever the channel does before its own first await — Velopack reads the install layout from disk —
/// between the user and their window. Yielding hands the rest of the launch back and lets the check run
/// in a later turn, which is the same moment in every sense that matters and none of the cost.
/// </para>
/// </remarks>
private async Task RunCheckLoopAsync(CancellationToken cancellationToken)
{
try
{
await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true);
await Task.Yield();
using var timer = new PeriodicTimer(CheckInterval, clock);
do
{
// Task.Yield takes no token, unlike the delay it replaced, so a shutdown that lands while
// the loop is waiting to be handed back the thread has to be observed here rather than
// only at the next tick. Otherwise an application closed during launch spends its last
// moment asking a release channel about a build it is not going to run.
cancellationToken.ThrowIfCancellationRequested();
await CheckOnceAsync(cancellationToken).ConfigureAwait(true);
}
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true));
@@ -264,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;
}
@@ -325,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
@@ -368,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
@@ -392,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;
}
}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more