Compare commits

...
Author SHA1 Message Date
jaap-jan b08f2c58e3 Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address
ci / desktop nightly (pull_request) Skipped
ci / build and test (pull_request) Failing after 3m42s
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 5m24s
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting.

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

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

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

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

Client, in Client.Session:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Server, in Features/Relay:

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

Client:

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

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

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

Tests: API 245, shell 582 (5 skipped), layout 170, SSH 110 including a
real login through the bridge, API client 30, contracts 25. Not run: the
Android build (its change is copy only) and the Alpine layout oracle.
2026-09-28 14:37:20 +02:00
jaap-jan 54bc142dce Merge pull request 'Stop an attach's own replay landing in the middle of a test's frames' (#18) from claude/fix-failed-test-3e1719 into main
ci / api image (push) Failing after 15s
ci / build and test (push) Successful in 3m23s
ci / android head (push) Successful in 3m48s
ci / desktop nightly (push) Successful in 1m29s
Reviewed-on: #18
2026-09-15 12:46:46 +00:00
jaap-jan 8e61fc72b4 Stop an attach's own replay landing in the middle of a test's frames
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 8m7s
ci / build and test (pull_request) Successful in 7m43s
ci / api image (pull_request) Failing after 11s
TheClipboard_ReachesThePaneThatAskedForIt failed in CI on an opcode of 2
where it wanted 8: a second SessionOpened frame where the clipboard's
should have been. Only one thing sends a session a second SessionOpened,
and that is ReplayAfterAttachAsync, the replay a reattaching page gets.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:05:12 +02:00
laurensandClaude Opus 5 dabc691c35 Keep the doorbell listening between callers, and stop a hung test taking CI with it
ci / desktop nightly (push) Successful in 1m47s
ci / android head (push) Successful in 3m56s
ci / build and test (push) Successful in 2m58s
ci / api image (push) Successful in 22s
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 / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 26m51s
ci / desktop nightly (push) Canceled after 0s
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 / desktop nightly (push) Skipped
ci / build and test (push) Failing after 3m1s
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 / android head (push) Successful in 3m56s
ci / build and test (push) Failing after 3m0s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
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 / api image (push) Successful in 23s
ci / build and test (push) Successful in 6m25s
ci / desktop nightly (push) Successful in 2m4s
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 / android head (push) Successful in 4m5s
ci / api image (push) Failing after 10s
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 / android head (push) Successful in 3m54s
ci / api image (push) Successful in 30s
ci / build and test (push) Successful in 3m18s
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 / desktop nightly (push) Successful in 1m22s
ci / build and test (push) Successful in 3m28s
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 / desktop nightly (push) Successful in 1m28s
ci / api image (push) Successful in 26s
ci / build and test (push) Successful in 5m43s
ci / android head (push) Successful in 4m4s
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 / android head (push) Successful in 3m29s
ci / api image (push) Successful in 28s
ci / build and test (push) Successful in 2m51s
ci / desktop nightly (push) Successful in 1m18s
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 / android head (push) Successful in 3m43s
ci / build and test (push) Successful in 3m9s
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 / desktop nightly (pull_request) Skipped
ci / build and test (pull_request) Successful in 2m49s
ci / android head (pull_request) Successful in 3m10s
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 / api image (push) Successful in 24s
ci / build and test (push) Successful in 3m22s
ci / desktop nightly (push) Successful in 1m26s
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 / desktop nightly (push) Successful in 1m10s
ci / api image (push) Successful in 49s
ci / build and test (push) Successful in 2m55s
ci / android head (push) Successful in 3m28s
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 / api image (push) Successful in 32s
ci / android head (push) Successful in 3m15s
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 / api image (pull_request) Successful in 4s
ci / build and test (pull_request) Successful in 2m32s
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 / api image (pull_request) Successful in 23s
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 / api image (push) Successful in 36s
ci / desktop nightly (pull_request) Skipped
ci / android head (push) Successful in 3m27s
ci / build and test (push) Successful in 2m28s
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 / desktop nightly (push) Successful in 46s
ci / api image (push) Successful in 33s
ci / build and test (push) Successful in 2m47s
ci / android head (push) Successful in 3m47s
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 / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 21s
ci / android head (pull_request) Successful in 3m19s
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 / api image (push) Successful in 37s
ci / android head (push) Successful in 3m46s
ci / build and test (push) Successful in 3m42s
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 / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / android head (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 / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Failing after 6s
ci / build and test (pull_request) Failing after 7s
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 / android head (pull_request) Successful in 3m29s
ci / api image (pull_request) Successful in 5s
ci / build and test (pull_request) Successful in 2m18s
ci / desktop nightly (pull_request) Skipped
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 / android head (push) Successful in 3m38s
ci / build and test (push) Successful in 2m41s
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 / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m22s
ci / build and test (pull_request) Successful in 2m13s
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 / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / build and test (push) Failing after 8s
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 / build and test (pull_request) Failing after 12s
ci / api image (pull_request) Skipped
ci / android head (pull_request) Failing after 12s
ci / desktop nightly (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 / android head (push) Successful in 3m36s
ci / api image (push) Skipped
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 / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m34s
ci / build and test (pull_request) Failing after 2m33s
ci / api image (pull_request) Skipped
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 / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / build and test (push) Failing after 2m22s
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 / desktop nightly (push) Canceled after 0s
ci / android head (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
jaap-jan 7f77539ba6 Merge pull request 'Give the desktop a macOS head, signed from the first release' (#3) from claude/macos-build-release-2a8a0d into main
ci / build and test (push) Failing after 2m4s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m34s
Reviewed-on: #3
2026-08-10 08:58:59 +00:00
jaap-jan ca081af209 Merge branch 'main' into claude/macos-build-release-2a8a0d
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m12s
ci / build and test (pull_request) Failing after 2m10s
ci / desktop nightly (pull_request) Skipped
2026-08-10 08:58:50 +00:00
jaap-jan 890a5f2246 Give the desktop a macOS head, signed from the first release
ci / android head (pull_request) Canceled after 0s
ci / desktop nightly (pull_request) Canceled after 0s
ci / api image (pull_request) Canceled after 0s
ci / build and test (pull_request) Canceled after 1m21s
The same application, the same Velopack and the same two-phase person-run
release as Windows, with four things forced to differ. Signing is a
precondition rather than an improvement: Gatekeeper refuses an
un-notarized download outright instead of warning about it, so there was
never the "unsigned for now" that ADR 0013 decision 8 argues for on
Windows, and release-macos.sh refuses to start without the identities.

The packaging split is narrower than it first looked, and the old claim
at the foot of ci.yml is why it was worth checking rather than assuming.
vpk cross-compiles when told to: 'vpk [osx] bundle' builds a real .app on
any platform, and CI now publishes osx-arm64 and bundles it on every main
and tag build, which is what catches a restore graph with no macOS native
asset. There is no '[osx] pack' off a Mac, and that part is correct — pack
drives codesign, notarytool and stapler, which exist nowhere else.

The dylib signing loop in the script looks redundant beside vpk's own
pass and is not. vpk signs with 'codesign --deep', which is the shape
Apple documents as wrong for nested code, and platform-flags has recorded
a notarization rejection that names no file since before any of this
existed. Signing each native binary inside-out first leaves that pass
nothing to get wrong.

MacDeviceKeyStore reaches ADR 0007's conclusion through different
hardware: a P-256 key in the Secure Enclave under an access control
requiring user presence, so the platform enforces the gate rather than
this process — which is the whole point of that ADR's amendment. The
enclave holds no other kind of key, hence ECIES where Windows uses
RSA-OAEP, and the shape that falls out is better than the Windows one:
sealing needs only the public half and is silent, so only unlock prompts.
IsSupported probes rather than infers, because three ordinary Macs answer
no — an Intel machine without a T2, one with no login password, and every
unsigned development build, since enclave keys need a signing identity.

Two decisions worth stating because they are reversible. arm64 only: a
second channel is small work and nobody here has an Intel Mac to walk
Phase 18 on, and an x64 package would be the only artefact in this
repository reaching users unverified. And the pack id stays
DodoSSH.Desktop even though vpk names the bundle after it, so
/Applications holds DodoSSH.Desktop.app: decision 2's reasoning binds
harder here, because a pack id of DodoSSH would put Velopack's install
root on top of ClientPaths.DataDirectory and let an uninstall take the
user's un-synced outbox with it. CFBundleDisplayName puts the product
name back in front of a person.

Measured rather than assumed, since none of it is obvious: the publish
and the bundle were both run, LSMinimumSystemVersion is 12.0 because that
is the minos in the apphost's own LC_BUILD_VERSION, and vpk copies a
custom Info.plist verbatim with no substitution at all — which is why the
plist is a template the script renders and not a committed file.

What is not done is the half that needs the hardware. There is no macOS
runner, so nothing past "it bundles" has ever run. Phase 18 is the whole
of the verification, and the two checks most likely to fail are the
terminal against WKWebView and the enclave interop, neither of which has
executed once.
2026-08-10 10:43:28 +02:00
jaap-jan 8c67fce32c Centre a phone row's caption in the row it is given
ci / android head (push) Successful in 3m27s
ci / build and test (push) Successful in 2m18s
ci / desktop nightly (push) Failing after 1m45s
ci / api image (push) Successful in 39s
Button.row sets the height a thumb needs and left the caption's placement to
Avalonia's Stretch default, so the content presenter stretched the caption to
the whole row and a TextBlock draws its line at the top of what it is given —
the same omission the desktop head's ghost/accent/danger rule had.

Most of the thirty-three rows never showed it, which is what made the four that
did look like four unrelated mistakes rather than one rule: a row whose content
is a StackPanel or a Grid of already-centred children is centred whatever this
property says. The four that are a bare TextBlock are FilesScreen's breadcrumb
crumb, its up-one-directory chip and its pinned-path chip, and TerminalScreen's
CLOSE THIS TAB — 36 or 44 tall with no vertical padding, so measured at those
numbers the caption sat flush against the top edge with 21 to 33 pixels of
nothing under it, eleven to seventeen pixels off centre in a control barely
twice that tall.

Nothing is excluded here, unlike the desktop's own sweep: no row's content
depends on being stretched — there is no full-height strip inside any of the
thirty-three, the thing that keeps flat and cat out of the equivalent rule over
there — and the Grids that stop filling hold only children that already centre
themselves, so they land where they always did.

The other two phone classes that do not set it are both fine and neither should
get it. RadioButton.chip declares its own ControlTemplate whose presenter reads
VerticalAlignment="Center" outright, so it centres regardless and the property
would not be read; Button.scrim is the full-screen dimmer behind a sheet and
has no caption at all.

Not covered by a test, and it cannot be from here: there is no Android layout
suite, the desktop harness cannot instantiate net10.0-android views, and
AvaloniaRuntimeXamlLoader — which would let it load Phone.axaml on its own —
lives in a package this repo does not reference. What is verified is that the
head builds, so the Avalonia XAML compiler has accepted the setter, and that
the desktop's own 147 layout tests are unmoved.
2026-08-10 10:35:38 +02:00
jaap-jan 0ffd259ccd Give the rest of the button shapes their content alignment too
ci / api image (push) Successful in 24s
ci / build and test (push) Successful in 2m28s
ci / desktop nightly (push) Successful in 50s
ci / android head (push) Successful in 3m15s
The sweep the ghost/accent/danger fix implied: navuser, poprow, panechip,
chiptoggle and choice each set VerticalContentAlignment now, because each set
everything else about how its content sits and left that one to Avalonia's
Stretch default.

None of them was misbehaving. Every one is content-sized everywhere it is used
today, so Stretch and Center agreed and this moves nothing — 113 buttons across
29 screens and cards measured byte-identical before and after, the strips that
have no height of their own included. What it buys is that the day one of them
is given a height, it is already right rather than quietly drawing its label in
the top third.

flat and cat are deliberately NOT swept in, and the reasoning that would sweep
them is exactly the trap. flat carries the titlebar's search pill, a
Border.searchpill with no height of its own that is meant to fill all 35 pixels
of its button — the usage states HorizontalContentAlignment="Stretch" and takes
the vertical default to match. cat carries the keychain rail's accent strip, a
Border.rowmark whose style sets Width="2" and no height at all, "at full row
height" by its own remark. Centring either from the style shrinks a pill and a
strip that are correct today. AStretchingShapeStillFillsItsButton pins both, and
fails when flat is centred.

ButtonCaptionTests covers the five new shapes on the existing rule. Its
stretch-fill assertion reads the content slot off the presenter rather than
recomputing it from the button's Padding: the shapes differ in whether their
presenter also draws a border, and a hand-rolled sum was two pixels out on
Button.cat for that reason.
2026-08-10 10:20:26 +02:00
jaap-jan 9bc9069425 Post the terminal's focus return past the dispatch that steals it
ci / build and test (push) Successful in 2m29s
ci / android head (push) Successful in 3m23s
ci / desktop nightly (push) Successful in 54s
ci / api image (push) Successful in 25s
The first fix handed Android's focus back from inside the keys' Click
handlers — which fire inside the UP event's dispatch, and Avalonia's
own view requests focus for itself after every handled touch dispatch
returns (AvaloniaView.DispatchTouchEvent, decompiled from 12.1.1). So
the platform's request ran after ours and undid it microseconds later,
which is exactly what the phone showed: the terminal still lost focus.

The return is now posted onto the main looper, landing one message
after the dispatch that stole, and it is wired at the row for both
halves of a press — DOWN steals too, and Click only exists for UP, so
a keyboard detached at DOWN would otherwise stay detached for the whole
length of the press. Check 11.10a now also says what a tolerable blink
looks like against a failure that stays.
2026-08-09 21:35:08 +02:00
jaap-jan e936ab4646 Announce a session's end when it is actually over, and for closes too
ci / desktop nightly (push) Successful in 41s
ci / build and test (push) Successful in 2m27s
ci / api image (push) Successful in 28s
ci / android head (push) Successful in 3m17s
The phone's notification kept saying '1 shell connected' after the
shell was gone, and both close routes were at fault. A shell exiting on
its own raised SessionEnded from inside its run's finally block — where
the run task is by definition not yet complete, so the LiveSessionCount
the keep-alive reads still counted the dead shell, and nothing fired
later to correct it. A tab closed by hand announced nothing at all, by
a recorded decision that assumed every subscriber was the closer; the
keep-alive is not, and a close it never heard about left the
notification claiming a shell over nothing.

The end is now announced from a continuation after the run completes,
and CloseSessionAsync announces after its own drain — every subscriber
was already a reconcile-to-reality handler, so the echo the old remark
feared costs nothing. Shutdown stays silent: it is dismantling the
subscribers along with the sessions.
2026-08-09 13:01:14 +02:00
jaap-jan 506d2803a2 Hand Android's own focus back to the terminal after an accessory key
ci / android head (push) Successful in 3m22s
ci / desktop nightly (push) Successful in 41s
ci / api image (push) Successful in 26s
ci / build and test (push) Successful in 2m27s
Focusable=false was only ever half the fix, and its remark now says so:
Avalonia's focus stays on the NativeWebView, but the touch that presses
a key still hands Android's native focus to Avalonia's input view — the
platform moves it before Avalonia decides anything. The WebView's input
connection dies with it, the keyboard swaps to its no-input layout, and
the inset churn parks it over the very row that was tapped.

Each key now returns that focus once its byte is on the wire, through a
sibling of SoftKeyboard that walks the decor view to the one WebView
this application has. Free when nothing moved. Check 11.10a is the
phone-in-hand proof.
2026-08-09 11:35:15 +02:00
jaap-jan cc8bf37321 Merge branch 'claude/terminal-reattach'
ci / api image (push) Successful in 37s
ci / build and test (push) Successful in 2m30s
ci / android head (push) Successful in 3m25s
ci / desktop nightly (push) Successful in 37s
Brings the Android keep-alive corrections and the terminal renderer
reattach: the foreground service now actually comes up for shells and
an idle Files session, survives refreshes from the background, and the
terminal's data plane lets a reloaded WebView page take its socket back
over instead of freezing every session behind a dead one.
2026-08-09 10:59:01 +02:00
jaap-jan 3f5979d639 Record the renderer-reattach correction and its phone checks
The port notes carry the third correction of this round: the data
plane assumed a renderer that attaches once and lives forever, which no
foreground service can make true of Android's separate WebView renderer
process. Phase 11 gains the two checks a phone can run — close and
reopen a connection, and a backgrounded shell surviving its renderer
being killed, banner and all.
2026-08-09 10:54:45 +02:00
jaap-jan aaff81272a Teach the page and the shell to put a reattached view back together
The page's socket now retries itself forever with backoff — a dropped
socket is an ordinary event on a phone, not the end of the terminal's
life — and createSession is idempotent, so a replay landing on a pane
that survived changes nothing. A replay creating a pane that did not
survive writes one dim line saying the earlier output stayed on the
host, because that is the truth about a reloaded page's scrollback.

The shell answers RendererReattached with the two things only it owns:
the font size, and which tab is active.
2026-08-09 10:54:38 +02:00
jaap-jan 4d1f07f253 Replay the live sessions to a renderer that just attached
Each live session gets its credit window reset — the unacknowledged
bytes died with the old page, and their acknowledgement is never
coming — and its SessionOpened frame again, flagged as a replay so the
page can tell a reattach from a genuinely new session. A session whose
shell already ended gets nothing: its scrollback lived only in the page
that is gone, and a frame implying otherwise would lie.

RendererReattached is the seam for what the workspace has no business
owning: the font size and the selected tab live in the shell, which
re-pushes them from its own subscription.
2026-08-09 10:54:29 +02:00
jaap-jan 095774c498 Take a returning renderer's socket over instead of refusing it
One attach per process was WebView2's truth, not Android's: the phone
kills the WebView's renderer independently of the app process, the page
reloads, and its fresh socket was answered 409 by a guard that never
reset — with no way back short of restarting the app. Only our own page
knows the token, so a second valid upgrade is that page returning; it
now displaces the old socket, which may never notice it is dead on its
own, since a killed renderer sends no FIN.

A send into the dead socket also no longer escapes as a fault. It used
to unwind the pump's flush loop, after which nothing drained the credit
window and the still-live shell froze behind it for good — including
the BCL quirk where such a send surfaces as an OperationCanceledException
nobody's token asked for.
2026-08-09 10:54:20 +02:00
jaap-jan 3977f68870 Record the keep-alive corrections in the port notes and the manual checks
The port doc's backgrounding decision now carries the four corrections
rather than describing a wiring that was not true, and Phase 14 gains
the checks a phone can actually run: a backgrounded shell surviving, an
idle Files connection surviving, the permission ask arriving at the
first thing worth showing, and a refusal costing the notification and
nothing else.
2026-08-09 10:14:24 +02:00
jaap-jan 48ea5e22d5 Actually keep the phone's sessions alive when the app is backgrounded
The foreground service existed, and four defects in its wiring meant it
mostly did not run. A shell opening was never announced to it — only the
ending was — so the service never came up for a shell at all. An idle
connected Files session counted as nothing. Every refresh restarted the
service, which Android 12+ answers with a crash the moment the app is
backgrounded — a transfer finishing in the pocket took the remaining
connections with it. And POST_NOTIFICATIONS was declared but never
requested, so on Android 13+ the receipt was silently invisible.

Updates while backgrounded now go through the notification manager; a
foregrounded refresh still prefers a real start, so a stop still in
flight cannot leave an orphan receipt over an unprotected process.
2026-08-09 10:14:17 +02:00
jaap-jan 810bc48d3f Tell the keep-alive wire when the Files session opens and closes
HasLiveFileSession answers the phone's foreground-service question — is
there a connection here that dying with the process would sever — and a
bucket answers no, because HTTP holds nothing open. ActivityChanged now
also fires at the end of MarkHostConnected and CloseSessionAsync, where
both facts it reads are finally true together.

Also makes the bucket pins test actually open a bucket: it never set
Remote, so CONNECT dialled the auto-selected host, and its assertions
passed only because that host had no pins either.
2026-08-09 10:14:10 +02:00
jaap-jan 671611a9a0 Merge branch 'claude/phone-pins' 2026-08-09 09:46:50 +02:00
jaap-jan 21cf77f64a Centre a button caption in the button, not just the button in its parent
ci / build and test (push) Successful in 2m28s
ci / android head (push) Successful in 3m18s
ci / desktop nightly (push) Successful in 47s
ci / api image (push) Successful in 27s
App.axaml's Button.ghost, Button.accent, Button.danger rule set
VerticalAlignment and never VerticalContentAlignment. The first places the
button in its parent; the second places the caption in the button, and its
default is Stretch — so on any of these given a fixed Height the content
presenter stretched the caption TextBlock to the whole content box, and a
TextBlock draws its line at the top of whatever it is given. Measured on the
hosts toolbar, whose three buttons are 40 pixels: nine above the ink and
twenty below it. Every box was the height it declared, which is why this read
as one of them being the wrong height — nothing was mis-sized, the labels sat
in the top third.

Center rather than a hand-tuned Padding, because the gap is the difference
between the line box and the content box and moves with the font size: these
carry 11.5 by default and the primary action overrides it to 13.5. It is the
three shapes that were missed rather than a new idiom — navseg, sesstab,
headerghost, sidebarrow, fieldrow and paneicon all state it already, as does
every one of the phone head's own button classes. 134 buttons carry these
three classes; the ones that show it are those with an explicit Height, which
is both toolbars, the drawer's Save/Cancel pair, and the import screen. A
button sized to its own caption was already right and is untouched.
HorizontalContentAlignment is deliberately left alone: it is Stretch too and
invisible on a self-sized button, and the flyout rows that are stretched wide
ask for Left themselves.

ButtonCaptionTests measures a bare Button, since Application.Styles is global
and a screen-level test would pin one toolbar and leave the rest to the same
defect. It measures the laid-out line rather than the TextBlock's arranged
bounds, and that distinction is the test: under Stretch those bounds fill the
content box and so are symmetrical whether or not the ink in them is. The
first draft asserted on them and passed against the defect; the calibration
test caught it, and against the old markup all three shapes now fail naming
their own gap.
2026-08-09 08:02:15 +02:00
jaap-jan dbf6ce1bcf Give the phone its pins: an editor section and chips on Files
The data was never the gap — HostSecret.PinnedPaths syncs and merges on both
heads, and the desktop's drawer has staged it since v5 — the phone just had
nowhere to add, remove or use a pin. Now it has both halves.

The host editor page gains a QUICK ACCESS section over the same shared
staging the drawer binds (EditorPinnedPaths, AddEditorPin, RemoveEditorPin),
with the remove target at this head's 44dp touch floor rather than the
desktop's 22-pixel close box, and no folder glyph because this head embeds no
icon font for one. The page also gains a Status line of its own: the add
command's five refusals speak through Status, and this page covers the screen
that normally draws it — a refusal nothing shows is no refusal at all.

The Files screen draws the connected host's pins as chips between the
breadcrumb and the listing, each running GoRemoteCommand exactly as a crumb
does. They are captured at connect, like ConnectedTo and the session facts
before them; a bucket gets none, having no HostSecret to pin anything on.
Covered headlessly in ShellFlowTests — connect populates, disconnect clears,
a bucket stays empty — and by manual checks 8.18 and 8.19, whose phase
preamble also stops claiming thirteen checks when it lists twenty-one.
2026-08-08 23:09:57 +02:00
jaap-jan 242280ce6b Name a root chip after the root, not with its whole path
ci / api image (push) Successful in 33s
ci / build and test (push) Successful in 2m15s
ci / android head (push) Successful in 3m24s
ci / desktop nightly (push) Successful in 43s
The chip derivation was TrimEnd(separator), which is a name only for the
Windows drives it was written against: on Unix it made the / chip an empty
pill and the home chip the entire home path, drawn at full width in a header
column nothing bounds. A home directory deep enough — CI's per-job HOME is
forty-six characters — had that one chip walk the header's own buttons out of
the window at the session shell's 472-pixel budget, which is the half of the
runner's red suite the star-column fix before this one did not reach.

A chip says C:, /, ~, or a mount's last segment now; the full path stays on
its command parameter, where length costs nothing. RootChipNameTests pins the
derivation with fixed strings, so it no longer takes a machine with a deep
profile path to ask the question.
2026-08-08 22:38:41 +02:00
jaap-jan de0b5f12ae Let the pane headers' paths actually trim
ci / build and test (push) Failing after 2m11s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m18s
TextTrimming only acts when measure hands the block a finite width, and a
horizontal StackPanel never does — it measures every child at infinity and an
Auto grid column passes the full answer on. So both SFTP pane headers grew
with their path, and a directory deep enough pushed the header's own icon
buttons past the window's edge at the session shell's 472-pixel budget.

The layout suite has said so on every CI run since v5b landed, and nowhere
else: the runner's per-job HOME is a 46-character path, which is what the
local pane opens on, and every developer machine's short profile path left
the same test green. The path sits alone in the star column now — bounded
width, working ellipsis — and the narrowest-budget test pins both panes to
sixty-character paths so the question is asked on every machine alike;
against the old markup that test fails on Windows too.
2026-08-08 22:28:26 +02:00
jaap-jan 009b35e069 Cover the mixed-keychain regroup refusal headlessly
The two-keychain branch of VaultViewModel.RegroupChosenHosts had no test:
7.6a's manual walk was the only thing asserting that a mixed set gets the
sentence instead of the picker. A ShellFlowTests case now ticks a host in
each of two vaults, reads the refusal off the status line, and shows the
same command opening the picker once the set is one keychain's again.
Check 7.6a cites the test and keeps only the popup wiring for the eye.
2026-08-08 22:19:46 +02:00
jaap-jan d32f5609e3 Rewrite checks 7.6/7.6a for the picker that replaced the drag
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m29s
ci / build and test (push) Failing after 2m9s
The drag onto a group card went with v5's flat sections; filing a set
is the chosen-hosts menu's "Change group..." picker on both heads now.
The two checks walk that route instead and say honestly what
ShellFlowTests and ScreenLayoutTests already cover, what only a real
popup can show, and that nothing automated raises the mixed-keychain
refusal. The numbering preamble's example swaps to citations that
still exist.
2026-08-08 22:12:44 +02:00
jaap-jan 9d5ff9f23a Draw what authenticated, and over what, on the session status bar
ci / build and test (push) Failing after 2m20s
ci / desktop nightly (push) Skipped
ci / android head (push) Successful in 3m27s
ci / api image (push) Skipped
The v5b design's own row: the negotiated cipher, then the host key's algorithm
and the name of the key or credential that authenticated, as one mono run
beside CONNECTED — on both surfaces, off MainWindowViewModel's surface-aware
SessionCipher and SessionIdentityText, the same shape SessionAddress set.

The identity's name comes out of TryBuildAuthentication, the one resolution
point that always had it in scope and always threw it away; it rides
HostAuthentication to the tab and to the SFTP connect alike. Three deviations,
recorded in the gaps doc: the algorithm prints as negotiated rather than
shortened, the run is plain text because no pin-details modal exists for an
open session, and a typed password shows the algorithm alone — there is no
item behind the dot. A dead terminal tab keeps its facts for the scrollback
still on screen; an SFTP disconnect, with no scrollback, clears them.
2026-08-08 20:54:56 +02:00
jaap-jan 8209f15741 Let a session's transport say what it negotiated
ISshConnection and ISftpSession both carry Cipher now — the server-to-client
algorithm off SSH.NET's own ConnectionInfo, captured once because a rekey is
not an event that library raises — and TerminalWorkspace.GetSessionFacts hands
that plus the host key's algorithm back per session, without ever handing over
the connection itself. Nothing reads either yet; the status bar that will is
the next commit.
2026-08-08 20:54:14 +02:00
jaap-jan 8915650a0d Record the phone catching up in the design-import log
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / build and test (push) Failing after 2m17s
ci / android head (push) Successful in 3m25s
2026-08-08 15:22:04 +02:00
jaap-jan b931a06998 Repaint the phone's chrome, radii and accent to the v5 vocabulary 2026-08-08 15:22:04 +02:00
jaap-jan ca48e18b57 Give the phone the desktop's face: Montserrat by default 2026-08-08 15:22:04 +02:00
jaap-jan c59b517fdf Record v5c in the design-import log, and true up the manual checks
ci / api image (push) Skipped
ci / build and test (push) Failing after 2m14s
ci / desktop nightly (push) Skipped
ci / android head (push) Successful in 3m27s
2026-08-08 14:17:20 +02:00
jaap-jan bb2f973687 Redraw the host keys screen with its pins' own facts beside it 2026-08-08 14:17:19 +02:00
jaap-jan c8507b44fe Give the application a settings area built from what really exists 2026-08-08 14:17:19 +02:00
jaap-jan 422d5ca10e Record v5b in the design-import log, and true up the manual checks
ci / api image (push) Skipped
ci / build and test (push) Failing after 2m12s
ci / desktop nightly (push) Skipped
ci / android head (push) Successful in 3m25s
2026-08-08 00:49:59 +02:00
jaap-jan d91729c0b8 Restyle the keychain, the snips and the logs to their v5b shapes 2026-08-08 00:49:59 +02:00
jaap-jan 43c939b697 Give the window its v5b chrome and each session surface its own shell 2026-08-08 00:49:59 +02:00
jaap-jan 1b76c51fbb Name v5b's deep chrome, its track and its magenta in the palette 2026-08-08 00:49:59 +02:00
jaap-jan f3c0b9ca1b Merge branch 'claude/friendly-elgamal-e085e5' into claude/v5-design-fidelity 2026-08-07 22:28:31 +02:00
jaap-jan 7ca74a1e35 Retint the scrims to the v5 canvas, quick connect at the spec's 60%
Five scrim hardcodes still dimmed through the old canvas #0E1220; they
now sit on #05050A. Each keeps its alpha except QuickConnect's backdrop,
which the v5 spec pins at rgba(5,5,10,0.6) — its essay drops from 80% to
60% of Canvas to match.
2026-08-07 21:29:57 +02:00
jaap-jan d7f0bea258 Repaint the launcher mark and the window icon in v5's own ink 2026-08-07 21:24:19 +02:00
jaap-jan 7d64027972 Record v5 in the design-import log, and let the working notes go
ci / build and test (push) Successful in 2m15s
ci / android head (push) Successful in 3m28s
ci / desktop nightly (push) Successful in 44s
ci / api image (push) Successful in 24s
2026-08-07 18:14:59 +02:00
jaap-jan 281e828e25 Sweep out the group-card navigation nothing reaches any more 2026-08-07 18:14:54 +02:00
jaap-jan dca2e888d6 Redraw quick connect in v5's shape, auth word and all 2026-08-07 18:12:36 +02:00
jaap-jan 2ba7c14e35 Restyle the drawer, pin folders on a host, and say when it was last connected 2026-08-07 18:12:31 +02:00
jaap-jan c3ef4bd8b4 Flatten the hosts screen into one board of sections 2026-08-07 18:12:22 +02:00
jaap-jan 369109dd4e Write the disabled accent glow as 'none', which this Avalonia can parse 2026-08-07 18:12:13 +02:00
jaap-jan 06f9dcfc27 Snapshot the v5 hosts screen mid-restructure, with handoff notes to resume it 2026-08-07 15:26:00 +02:00
jaap-jan bea0279937 Repaint both heads in the v5 palette and embed its three fonts 2026-08-07 15:25:51 +02:00
jaap-jan 49645db680 Let a host carry pinned folders, merged path by path 2026-08-07 15:25:51 +02:00
jaap-jan 82966af37b Let a connection be reached through a proxy on this machine's loopback
ci / android head (push) Successful in 3m20s
ci / api image (push) Successful in 23s
ci / build and test (push) Successful in 2m8s
ci / desktop nightly (push) Successful in 46s
Step 1 of docs/reaching-a-host-you-cannot-dial.md, and it is not the step that document said it was.

SshConnectionRequest carries an optional SshLoopbackProxy and BuildConnectionInfo hands SSH.NET its proxy
ConnectionInfo when there is one. Nothing passes one yet: the callers are jump hosts and the relay, which
are steps 2 and 3.

◆ THE BRIDGE WAS THE WRONG FIRST STEP, AND BUILDING IT WOULD HAVE BEEN THE MISTAKE THIS DOCUMENT IS ABOUT.
ADR 0004 says the relay's loopback bridge "also provides ProxyJump via a SOCKS5 dynamic forward — one
mechanism, two features", and the plan took that to mean the bridge was the shared foundation. It is not:
ForwardedPortDynamic *is* the listener for a jump host — SSH.NET accepts on it, speaks SOCKS5 on it and
tunnels through the bastion — so nothing is left for a bridge of ours to do on that path. The relay is the
case with no SshClient to hang a forward off, so it is the bridge's only consumer, and the bridge belongs in
the commit that uses it. What the two actually share is one level down and a tenth of the size: being told
to reach a target through a loopback proxy while staying about the target. That is what this is.

Three properties, one test each.

A port and nothing else, so a proxy anywhere but loopback cannot be expressed. The failure that shape rules
out is an open SOCKS proxy on the user's network for the life of a shell, which nothing would report — so it
is made unrepresentable rather than validated, on the same grounds AuthenticationChoice carries a kind.

SOCKS5 rather than a dumb pipe, which is what keeps host key pinning honest. The target's own name and port
stay in the request, travel to the proxy in the CONNECT, and are what the gate pins — so a machine reached
through a bastion is pinned under its own name instead of under 127.0.0.1 on whatever ephemeral port that
day's forward got, which is not an identity at all. A pipe would have meant handing SSH.NET a stand-in and
remembering everywhere else that it was one.

And a proxy that is not listening fails as a connection error rather than as an unknown host key. The gate
turns "no host key seen" into a fingerprint prompt, and a connection that never reached a server has seen
none either; the prompt would offer to fix the wrong thing, with no fingerprint to show.

TWO THINGS THE TESTS MEASURED RATHER THAN ASSUMED, both found by the first run failing.

The target is resolved at the *bastion*, not here — a SOCKS CONNECT names it and the far end looks it up. So
the test asks for localhost:2222, the address inside the container, and the published port this host would
use means nothing there. That is not a quirk of the fixture; it is what ProxyJump means, and it is why an
ssh_config writes the target's internal address beside its jump host. Getting it wrong is a SOCKS "general
failure" that names neither end.

And the test server refuses forwarding. linuxserver/openssh-server ships AllowTcpForwarding no, which a
dynamic forward does not notice — opening one asks the server nothing — so every connection through it is
refused at channel-open and reported as the same general failure. The fixture patches it and HUPs sshd.
There are two sshd_config files in that image and the running server uses /config/sshd/sshd_config; the
first attempt patched /etc/ssh/sshd_config, which is the one a search finds first, changed the text and
nothing else, and left the failure exactly where it was.

VERIFIED. Build clean with no new warnings, 85 tests in Client.Ssh.Tests against the real sshd, and the
solution builds. The proxy test was seen to fail — proxy.Port + 1 in BuildConnectionInfo — and seen green
again. An earlier mutation attempt did not compile, and the log said 85 passing because the run never
started and the previous log was still on disk; the second attempt deletes the log first, which is worth
copying whenever a mutation "passes".

dotnet format reports one pre-existing IDE1006 in DodoSSH.Api/Features/Events/EventsEndpoint.cs, in a
project nothing here touches. Left alone.
2026-08-07 13:48:15 +02:00
jaap-jan 575a9a9f5e Stop the relay checkbox promising a connection this client cannot make
ci / build and test (push) Successful in 2m7s
ci / android head (push) Successful in 3m15s
ci / api image (push) Successful in 25s
ci / desktop nightly (push) Successful in 47s
Ticking "Connect through the server relay" moved the host's address and port out of the encrypted payload
into plaintext columns on the server — the single deliberate privacy concession in the design, per ADR 0004
— and then the client dialled the address directly, exactly as it does with the box clear. VaultViewModel
builds SshConnectionRequest(hostname, port, username, credential) and nothing on this side reads
RelayEnabled at all. The connection failed the way it always had, for a machine the laptop could not reach,
with nothing saying the box had done nothing.

The server half is built and shipped: tickets, the WebSocket, the deny list, the CHECK constraint that
enforces a non-null address for a relay-enabled host. What does not exist is the client's path to it, so
this is an unfinished feature rather than a broken one — but the control in front of it was collecting the
cost of the finished version.

Both heads now say so, in the label and in the first sentence of the paragraph under it. Not disabled, and
that is the one decision here worth stating: a host somebody has already ticked has to be able to lose the
flag, and a control greyed out with the concession switched on would trap it there. Tickable and honest
beats untickable and stuck.

This is step 0 of docs/reaching-a-host-you-cannot-dial.md, and the only step of it that should ship alone —
the sentence is written to be deleted when the bridge lands.

VERIFIED. Build clean, 112 layout tests. The drawer's paragraph is longer than it was and the host editor is
measured with the drawer open at the window's minimum, so the wrap is held inside the column rather than
assumed to fit.
2026-08-07 08:47:02 +02:00
jaap-jan 6185d74800 Write down the two things a user is promised and does not get
Two plans, both for the same class of defect: a control or a code that a user is told to rely on, backed by
storage and by nothing else. Neither is started; what follows is the reasoning, so that starting is not
where it gets thought about.

── UNLOCKING WITHOUT THE PASSPHRASE ─────────────────────────────────────────────────────────────────────
Every account is issued a recovery code at enrollment. The client generates it, wraps the identity bundle
under KEK_rc, the server stores that wrap as UserKeyWrapKind.Recovery, and both heads work to make sure the
user writes it down — the phone raises FLAG_SECURE for that screen alone and will not let anybody past it.
Nothing can use it. SessionOpener has UnlockAsync and UnlockWithDeviceAsync, and there is no third.

Walk the failure through: forget the passphrase, and the bundle cannot be unwrapped, so no vault key opens
and every item is unreadable. Signing out and back in returns the same passphrase wrap. The device key
would be the other door, and sign-out withdraws it — which is the advice the unlock screen gives for
exactly this situation. The loss is total and permanent, and the thing built to prevent it is inert.

The docs already disagree with each other about this, which is how it surfaced. manual-checks §10.2 calls
the code "the only thing standing between a forgotten passphrase and an unrecoverable vault"; android-port
says losing it *along with* the passphrase is what makes a vault unrecoverable; README says signing out is
the only answer and nothing can recover one. The third is the true one today.

More than half the work is already done and one piece of it was done on purpose: LocalCacheKey derives from
the identity bundle rather than from MK — crypto.md §3.2, changed 2026-07-30 — specifically so an unlock
that never computes MK can still read the cache it wrote. What is missing is an endpoint to serve the wrap,
an unlock path, and a way to set a new passphrase afterwards, without which the account unlocks with a
one-time code forever. That last step is the same re-wrap a change-passphrase feature needs, so it delivers
both.

Two traps are recorded because both would produce a code that verifies nowhere. The derivation uses the
displayed string *including its dashes*, so the unlock must canonicalise to the printed form rather than
strip it; and the recovery wrap uses a different Argon2 profile to the passphrase one (64 MiB against 256),
so it must derive from the parameters served with the wrap rather than from a profile constant.

── REACHING A HOST YOU CANNOT DIAL ──────────────────────────────────────────────────────────────────────
This started as "delete the dead jump-host field" and inverted twice.

HostSecret.JumpHostIds is stored, validated, encoded and three-way merged, and nothing reads it or writes
it — the ssh_config importer looks like the writer and is not; it records ProxyJump as an option and a note
saying DodoSSH cannot honour it. The first draft recommended deleting it. That was wrong twice over. ADR
0004's last consequence had already designed the implementation — a loopback TCP bridge for the relay, and
"the same bridge provides ProxyJump via a SOCKS5 dynamic forward", one mechanism and two features — which
the pinned SSH.NET 2025.1.0 supports through ForwardedPortDynamic and ProxyTypes.Socks5, checked in
Renci.SshNet.xml rather than remembered. And the stored shape is right: an ordered list of host ids is what
a chain is, the merge arm is correct, and the missing schema version is a line to add.

◆ Looking properly found the same shape one field over, where it costs something. RelayEnabled is also
stored, merged and never read by the connect path — but it is user-settable, and both heads draw a checkbox
promising it. Ticking it moves the host's address and port out of the encrypted payload into plaintext
columns, which ADR 0004 calls the single deliberate concession in the design, and then the client dials
directly anyway. The privacy is spent and the feature is not delivered. That is a defect rather than a gap,
and it is step 0.

The comparison the plan turns on: the relay reaches what the *deployment* can reach and the jump host
reaches what a *machine in the keychain* can reach, so they are not substitutes. On a self-hosted box
outside the target's network the relay reaches nothing the laptop could not. And the privacy ordering is
the opposite way round from the ADR's framing — the relay costs a plaintext address, the jump host costs
nothing, because the operator is not in it.

Both documents carry a section on what their own earlier reasoning got wrong, which for the second one is
the load-bearing part: "nothing reads this field" was read as evidence of a mistake when it was evidence of
an unfinished feature — and the same sentence one field over would have found the checkbox that is lying.
2026-08-07 08:44:21 +02:00
jaap-jan 88809f0d66 Stop the docs claiming absences that have since been built
An audit of README.md, the seven docs and the fourteen ADRs against the code, looking for what is described
as absent or planned. Most of it held. What did not is here, and it clusters: every stale claim but one is
downstream of the settings file arriving without this document noticing.

design-import-gaps said the client has no preferences store and writes exactly two files. It writes three —
ClientSettings is in settings.json beside the cache — and two preferences are saved through it. From that
one error followed four more: the terminal font size row said "fixed at the renderer's 13px" when it has
been 8 to 32 from a screen and three chords for some time; the transfer-resume row and the per-host last
directory row both blamed a store that now exists, when what they actually want is a table and a scalar file
is the wrong shape for one; and the Preferences table asserted no preference could be saved at all.

It also said TerminalServerOpcode has four values and none carries an option. It has eight, and one of them
is FontSize — which is the interesting part rather than a counting error, because that opcode is the proof
that the frame these rows say is missing can be built. The rows now say what each one would actually take,
which for three of the four is a setting, an opcode and a control, and for the Backspace row is a reason:
which byte backspace sends is a fact about the remote's stty, so a client-side switch fixes a mismatch by
hiding it.

THE SAME TWO ERRORS WERE SHIPPED IN THE INTERFACE. The preferences screen carries a NOT BUILT YET list, so
that what the screen does not do is as legible as what it does — and it said terminal size was hard-coded a
hundred lines below a working size control, and said there is one release channel a month after the nightly
shipped. A list of absences is only worth having if it is true, and a screen contradicting itself in the
same scroll is worse than no list. Both lines are corrected rather than removed: the first now says which
three of the four are genuinely hard-coded, and the second says what is actually missing, which is a way to
change channel from inside the application rather than by installing the other build.

docs/adding-hosts-on-the-phone.md is deleted. It was a work plan whose own header says "Status: built. All
six steps." — nothing links to it, and the decisions it records are in the code it produced, including the
one it is proudest of: HostSecret.AsksForPassword carries its own remark on why naming neither binding had
to stop meaning "ask me". What was left was step ordering and per-test instructions for work that shipped.
Git keeps it.

crypto.md is deliberately untouched. It is normative and frozen, and its claims are about the DSH1 format
rather than about this build — including the one that reads oddly next to the code, that a passphrase is
one of four ways to open a vault. Under the spec it is. What is missing is a statement about what this
build can open, and that belongs beside the spec rather than inside it; see
docs/unlocking-without-the-passphrase.md.

VERIFIED. Build clean, 112 layout tests, 354 app tests. The preferences screen is measured by the layout
suite, so the longer copy is held inside the window at the minimum size rather than assumed to fit.
2026-08-07 08:43:49 +02:00
jaap-jan d8cf16fb46 Merge branch 'claude/windows-multiselect-support-37541c'
ci / android head (push) Successful in 3m24s
ci / api image (push) Successful in 24s
ci / build and test (push) Successful in 2m7s
ci / desktop nightly (push) Successful in 41s
2026-08-06 14:12:07 +02:00
jaap-jan 507cd9ff88 Choose more than one host card on the desktop, the way the phone already can
The chosen-hosts set has been in VaultViewModel since the phone's connect card became a contextual action
bar: a set of entity ids, a tick on the row, and seven things that can be done to it. Only one head could
fill it. The desktop's grid answered a press with one selection — the card the drawer, CONNECT and the
context menu are about — so filing eleven imported machines under a heading was eleven drags, and clearing
out a vault was eleven rounds of the deletion question.

So the pointer gets three ways into the same set. Ctrl-clicks a card to tick it, Shift-clicks to tick the run
between the anchor and the card, and drags a band out over the space between and below the cards to tick
everything it touches. Esc, CLEAR, a plain click on a card and a click on the empty space each drop it, and
Ctrl+A takes every card being drawn — VisibleHosts, so with something in the find box that is the ones on
screen and not the ones it is hiding, which is the version of that shortcut whose result can be checked
before Delete is pressed.

TWO SELECTIONS ON ONE SCREEN, AND KEEPING THEM FROM DISAGREEING IS MOST OF THE CHANGE. Ctrl and Shift are
answered on the tunnel and marked handled, so the ListBox never moves its own mark onto the card: a
Ctrl-click that also selected would light the card it had just unticked and open the drawer on a machine
somebody is removing from a set. A plain press drops the set unless it lands on a ticked card, and that case
is deferred to the release, because the press may be the start of a drag of all of it. After any ordinary
click exactly one card is in play, which is what makes every command on the screen unambiguous again.

The context menu is where the seven live, and it is one markup with two halves gated on IsChoosingHosts.
Connect, Browse files and Edit… are drawn only for a single ticked host, as the phone's sheet collapses them
and for the same reason; the other four read better for a count. A right click on a card outside the set
drops the set first, so a Delete… about the card under the pointer can never be offered while six sit ticked
behind the menu — the same rule OnContextRequested has always enforced for the selection, reached from the
other direction. No bar of buttons: the phone raises one because it has no other way to hold seven entries,
and a strip repeating a menu that already exists would be a second home for the wording that matters most.
What the desktop gains instead is a count beside the HOSTS heading, CLEAR, and a sentence saying where the
actions are.

A drag that starts on a ticked card carries every ticked card. The payload is a list rather than a row now,
and a drop of more than one goes through FileChosenHostsUnder, which makes the refusals once — an open
editor, and a group belonging to one keychain — and reports a count instead of forty status lines. Moving
whichever card the pointer happened to be holding and leaving the other five where they are is a gesture
that quietly does a fraction of what it looks like it does, and the five left behind look filed.

The three panels the set's actions raise had never been drawn in a window: the vault picker with its key
question, the group picker, and the deletion question. All three sit above the grid rather than over it,
which is the arrangement the GROUPS section and the phone's list already use and for the reason written
there — the ticked cards are the information the question exists to give, so the grid shortens instead.

A DEFECT FOUND BEHIND IT, AND IT WAS ALREADY LIVE ON THE PHONE. The deletion question names a count and the
run that answers it reads the set again, and nothing kept the two the same set: the panel is deliberately
above a live list, so one more tick between "Delete these 6 hosts?" and pressing DELETE deleted seven, with
the seventh named in nothing the user had read. It needed a deliberate act on a phone and a second's work
with a band, which is what turned it up. VaultViewModel now remembers which hosts the question was asked
about and drops the question when the set stops being them — the question rather than the set, because what
somebody has just chosen is what they meant. It also covers the case nobody performs: a colleague's deletion
arriving mid-question and shrinking the set under it.

VERIFIED. 354 tests in App.Tests and 111 in App.Layout.Tests, build clean, no new warnings. Six gesture tests
drive real pointer and key input through the headless window — the modifier click and what it must not do to
the selection, the run and its re-measurement from the anchor, the band and the click that drops the set,
Ctrl+A under a filter, and the menu's two halves — plus a DragOver carrying two hosts. Four layout tests
measure the strip and the three panels at the window's minimum; the vault panel binds a key to its host
first, or it would measure the short shape and certify the tall one. Two flow tests cover the multi-drop's
write and its refusal, and the deletion question dropping itself.

manual-checks gains 7.6a for dragging a set, which no test can see for the reason 7.6 gives, and 7.7a for the
gestures — the rectangle actually being painted and the tick and the fill being legible together are the two
things the harness cannot look at.
2026-08-06 14:11:48 +02:00
jaap-jan 7b616e0bb0 Merge branch 'claude/windows-update-bar-buttons-b19b2d'
ci / build and test (push) Successful in 1m55s
ci / android head (push) Successful in 3m25s
ci / desktop nightly (push) Successful in 43s
ci / api image (push) Successful in 24s
2026-08-06 12:36:18 +02:00
jaap-jan 36b8a23020 Give the update banner the view model it is typed to
The banner has never worked. It went into MainWindow's fourth row with no data
context of its own, so it inherited the shell's — and it is the one control in
that file typed to a screen's view model rather than to MainWindowViewModel,
because it is the only one with a layout suite that hosts it over UpdateViewModel
alone. Compiled bindings type-check against x:DataType at runtime, so every
binding inside it resolved against the wrong object and failed the way a compiled
binding does: quietly. No headline, and DismissBannerCommand and RestartNowCommand
both null.

A button with a null command is enabled, hovers, depresses and does nothing, which
is why this looked like a hit-testing problem and why the WebView was the first
suspect. It is not one. The strip is a sibling row for the reason the occlusion
rule gives and that arrangement is correct — the terminal's rectangle is never
covered, only shortened. What was actually on offer was an announcement that an
update had been downloaded, with two buttons that refused to install it and no
way to make it go away either. The preferences screen's RESTART NOW worked
throughout, because it binds Updates.RestartNowCommand from the shell's own
context, which is the contrast that pins the cause.

The context is set on the banner itself and IsVisible loses its Updates. prefix
with it, because a data context on an element resolves that element's other
bindings too — the rule the page area's wrappers upstairs exist to work around.
Those wrappers are needed because IsHostsScreen and its siblings belong to the
shell; IsBannerShowing belongs to the banner's own view model, so there is nothing
to wrap here.

Neither existing suite could have caught it. A layout test supplies the data
context it is measuring, which is exactly the assumption that was wrong, and the
shell suite has no visual tree — its project file already says it does not cover
whether the XAML binds to the right names. So the new test asserts the wiring
rather than the layout: a real shell over the ready-update fake, MainWindow
constructed and never shown, and the banner asked what context it got, whether it
is visible and whether RESTART NOW carries a command. Checked failing with the one
attribute removed. Constructing the window is safe where showing it is not, and
nothing here needs it shown: a data context propagates when it is set, not when
the tree is measured.
2026-08-06 12:35:06 +02:00
jaap-jan 1e8a1f2e83 Merge branch 'claude/trust-connect-popup-56abec'
ci / build and test (push) Successful in 2m6s
ci / android head (push) Successful in 3m28s
ci / desktop nightly (push) Successful in 43s
ci / api image (push) Successful in 25s
2026-08-06 12:27:20 +02:00
jaap-jan 4f9faa2fe3 Ask about a host key where the connection was made, not on the host list
The trust prompt was two banners at the top of the desktop's hosts screen, so the shell navigated there
before letting a handshake raise one: Screen = Hosts, Surface = Page, in OnVaultConnectionFailed and again in
the palette's own connect. The reason was sound — a connection can be started from Ctrl+K on any screen, and
a question behind whatever somebody is looking at is a question nobody can answer — and it was answered the
wrong way round. Rather than making the decision reachable from where the user is, it moved the user to where
the decision was, and charged every screen for it.

It is worst for the one connection that has no host at all. A machine typed into the phone's connect box is
deliberately in no keychain, so a first contact from there judged it on a list it does not appear on, after
taking the box that dialled it away.

So both heads now draw the decision over the surface. HostKeyCard is the desktop's, and is the counterpart of
the phone's HostKeySheet: a scrim with no press handler, because a question with two named answers must not be
answerable by missing; the unknown key offering TRUST AND CONNECT, because judging a fingerprint against what
an operator published is a decision a person is entitled to make and the only moment they can make it; and the
changed key offering no way forward at all, because a button beside that warning is "continue anyway" with two
clicks instead of one. The phone needed no new markup — its sheet was already a shell-level overlay, so
deleting the navigation is what puts it over the Connections screen.

IsHostKeyDecisionShowing is on the shell rather than on a screen because the answer decides an occlusion. A
second connection can be refused while a first one is open, so this card is routinely raised over a live
terminal, and that rectangle is a native child window: layered over it the card would be sliced at its left
edge with TRUST AND CONNECT taking no clicks, which for the most safety-critical question in the product is
the worst place for that class of bug to land. IsTerminalShowing gives the rectangle up instead.

The banners are gone rather than copied. One prompt in two markups is two copies of the most safety-critical
wording here, and the second is the one that goes stale.

TWO DEFECTS FOUND BEHIND IT.

VaultViewModel.RejectHostKey cleared only the pending key and never the mismatch, so the changed-key refusal
had no working exit. That was invisible for as long as it was a banner nothing was drawn over — nothing was
trapped, and the next attempt cleared it — and it was already live on the phone, where that refusal is an
opaque full-screen panel whose one button runs this command: pressing it left the panel up over every screen
the user went to next, including the host editor the panel tells them to open. TransfersViewModel.RejectHostKey
has always cleared both; the vault's was the outlier. Its button said BACK TO HOSTS, which was wrong twice
over, and now says BACK.

And an assertion written for this change could not fail: the palette test asserted the renderer was collapsed
in a scenario whose only tab had just been removed, so it was collapsed for want of a session whatever the
occlusion rule said. It is gone, with a note pointing at the test that can fail on it.

VERIFIED. 1580 tests, build clean, no new warnings, format clean. Three mutations each seen to fail and then
seen green again: dropping !IsHostKeyDecisionShowing from IsTerminalShowing, caught by
AChangedHostKey_CollapsesTheTerminalItIsRefusedOver; reverting RejectHostKey to clear one flag, caught by
RefusingAHostKeyDecision_TakesItOffTheScreen(false) and by that same test; and dropping the two host-key arms
from OnVaultPropertyChanged, caught by TheHostKeyDecision_IsAnnouncedToTheWindowWhenItArrivesAndWhenItGoes.

That last one is the first test in this repository to watch PropertyChanged, and it is worth being the first:
every other assertion about the flag reads it directly, and a direct read passes with the subscription
deleted — while the card would never go away.

The two layout tests moved with the prompts, from the hosts screen to the card. manual-checks gains 7.4a for
the occlusion, 7.4b for getting out of a refusal and 11.7a for the hand-typed case, none of which a test can
see; 1.5 and 7.4 are corrected rather than left describing a window that no longer moves.

ONE ROUGH EDGE, DELIBERATELY LEFT. On the desktop, refusing a first contact whose tab was the only one leaves
the terminal surface with no tabs — a blank rectangle under the strip's "no terminals open · press + or
Ctrl+K", which is the one sentence near that rectangle Avalonia can draw. The alternative was falling back to
the page, and on the phone that means the host list, which is the bug this commit is about. A desktop connect
page would close it properly.
2026-08-06 12:25:37 +02:00
jaap-jan e750ba05e3 Merge branch 'claude/edit-screen-refresh-items-63a808'
ci / desktop nightly (push) Successful in 45s
ci / build and test (push) Successful in 2m6s
ci / android head (push) Successful in 3m11s
ci / api image (push) Successful in 31s
2026-08-06 12:08:55 +02:00
jaap-jan 6d6edb02c1 Keep an open editor's pickers in step with the vault
The host editor's four pickers were snapshots taken when it opened, and the
comment on EditorAuthenticationChoices said why: a picker whose contents move
under somebody halfway through a form is worse than a list a minute stale, and
only one editor could be open at a time anyway, so the only way to add a key was
to close this one. The second half of that stopped being true when
AHostEditorIsInTheWay was split from AVaultEditorIsInTheWay. The host editor is
the Hosts screen's business and the keychain's editors are the Vault screen's;
neither refuses the other now, which was the right split — it stopped three
quarters of a screen going inert over an editor the user was not looking at — but
it left the assumption those snapshots rested on false and nothing to notice.

So the ordinary way of using the feature was the broken one. Somebody starts
editing a host, finds there is no key to bind it to, goes to KEYS, makes one, and
comes back to a picker that does not have it — with the fix being to throw the
form away and start again. The same for a password, a tag, a group, and for a
whole vault made on the Teams screen because the host being typed belongs to the
team rather than to the person typing it: the vault they had just made for it was
the one place they could not file it.

RefreshOpenEditors refills whichever editor is open, and it hangs off ReloadAsync
rather than off the twenty-odd commands that write to the vault. That is the
choice worth stating, because it is what makes a sync count as well as a save: a
key pulled from another machine reaches the open editor by the same path a key
typed here does, and a place that wrote to the vault without refreshing the editor
would be a bug nobody would find for months.

What the old comment was protecting against is real, so every picker is put back
onto what it was already showing, by id, and not one typed field is touched. An
editor that reset its own bindings because a background sync landed would be a
worse bug than the stale list this fixes — it would rebind a host as a side effect
of somebody else's work. The placeholder entries go back too, which is the case
3.4 measures: a group deleted on another machine mid-edit still cannot unfile the
host when the form is saved. The group editor gets the same treatment for the same
reasons; it shares the drawer, and its default binding is lent to every host under
it.

The snippet editor's vault picker was the same copy of the same list and went
stale the same way. It watches TargetVaults rather than the reload, because that
screen has always been a wrapper over the vault's collections and has no reload of
its own to hang off — which is how it already follows Snippets.

The move panels are deliberately left alone. A vault arriving from a sync while
one is open still will not appear in it, but a move panel is opened by the act that
fills it and its picker resets its selection to the first entry on every rebuild,
so refreshing it would move a destination somebody had chosen. Same class of bug,
different answer, and not this change.

Five tests, and four of them were checked failing with the RefreshOpenEditors call
commented out: a key reaching the open host editor and binding when chosen, an
item arriving without moving a selection that was already made, a tag arriving as
an unworn chip, a key reaching the group editor, and a vault reaching the host and
snippet editors without moving either. Manual check 7.12 sits beside 7.11, which
is this same bug on the files screen's picker, and says what the worse failure
would look like: a picker that moves rather than one that does not notice.
2026-08-06 12:08:25 +02:00
jaap-jan 808a9a7fc1 Open a new host in the vault of the group it is being made in
+ NEW HOST decided two defaults separately and let them contradict each other.
The group came from the screen — the selected card, or failing that the group
whose contents are showing — and the vault came from the keychain screen's
standing "new items go to" preference. Inside a group belonging to any other
vault the two disagreed, and the group is what lost: GroupInEditingVault drops a
group the editor's vault has not got, on the sound reasoning that a host filed
under an id its readers cannot resolve looks unfiled to everybody but the person
who wrote it. So pressing the button while standing inside a team's PLATFORM
opened a form filed under nothing, bound for the personal vault, with no sentence
anywhere saying either thing had happened.

The vault now follows the group. A group lives in exactly one vault, so a host
that is to land in that group has to be sealed in that vault too — which is the
rule + NEW GROUP has followed for a parent since the cards became a tree, and the
comment there claiming this as a deliberate difference from the host's editor is
the one the code has now caught up with.

The filter stays, because there is one case left for it: the group's vault may be
one this session can read and not write, a team vault this account is a viewer of.
TargetVaults is the readable-and-writable set and is what decides here, so a
viewer keeps the standing preference and loses the group with it, rather than
opening an editor aimed at a save that cannot happen.

Both directions are tested, since one alone would not say which default wins:
standing in a shared vault's group, the editor opens on that vault with the group
selected and the host saves there; and with the preference pointed at the shared
vault while a personal-vault group is open, the group beats the picker somebody
set once.
2026-08-06 12:08:15 +02:00
jaap-jan f1d6499bb5 Merge branch 'main'
ci / api image (push) Successful in 33s
ci / build and test (push) Successful in 2m3s
ci / android head (push) Successful in 3m21s
ci / desktop nightly (push) Successful in 45s
Two of main's changes land in files this branch rewrote, and both needed carrying
across by hand rather than by the merge.

The phone's nav staying up on Connections with nothing running is a fourth input
to RefreshChrome, which this branch had already given two more — whether hosts are
ticked and whether the host editor is filling the screen. They compose: the rail
and the bottom bar now ask (pages || connectPage) && !editing, so a page-shaped
terminal surface keeps its way off the screen and the editor still takes the whole
display.

The key question under the host's move panel is the harder one, because this
branch deleted the panel it was added to. The connect card is gone and the phone's
only route to a move is the action bar, so leaving the merge to take this side
would have removed a capability main had just shipped — silently, since nothing
would fail to build. It is asked in the action bar's own picker instead, in two
shapes fewer than the desktop's: one host, because which key to carry is a fact
about one machine and a selection of six has six answers, and a move rather than a
copy, because taking the key out from under an original that is staying put would
leave that original unable to connect. BindingOfTheMovingHost splits into
MovableBindingOf so both heads answer it the same way from different panels.

Main also fixed a real trap in the same commit — a host that only inherited its
key from its group arrived in the destination naming nothing at all, because the
group stays behind — and the batch move had the same bug for the same reason. It
goes through Detached now, which is where that fix lives.

The carried host is written as the carry left it rather than being detached again,
which is the one thing worth measuring: the key takes a new id over there, so a
run that rebuilt the payload from the row would send the machine across naming a
tombstone. Both directions are pinned, along with the rule about which shapes the
question is asked in at all.
2026-08-06 09:30:00 +02:00
jaap-jan c882fa0cd3 Give the phone a selection instead of a card under the list
A long press on a host raised a connect card over the bottom of the list: a
password box, CONNECT, EDIT, MOVE and DELETE. It was the right idea in the wrong
place. It covered rows, it had room for five things and never a sixth, and every
one of them was about exactly one machine — so filing eleven imported hosts under
a group was eleven trips through a form, and there was nowhere to put a sixth
action if anybody wanted one.

A long press now chooses the host it landed on, and the actions move into a bar
across the top of the screen, in the vault header's place rather than beside it.
That is where Android has put them since contextual action bars existed, and it
is the one strip a list can never grow into — but the real reason for it is that
while it is up the screen is unambiguously about the ticked hosts and nothing
else, which is what lets the count in the middle of it mean something. Left to
right: the cross that leaves the mode, the count, the pencil, and a ⋯ holding
Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate
and Remove.

A tap still connects and still raises nothing. Once anything is ticked it ticks
and unticks instead, which is what every Android list does and is not merely a
convention worth following: a tap that connected while five machines sat ticked
would open a terminal on top of a selection somebody was halfway through
building. Unticking the last host leaves the mode, so there are two ways out of
it and the cross is only one of them.

Both gestures now read the row from the element under the finger rather than from
the list's selection, and that is a correctness change rather than tidying. A tap
on a group heading moves the selection and the view model bounces it straight back
to whichever host was chosen before — which answered "a host, or nothing" for free
while a tap only ever connected. It stops answering it the moment a tap can tick
one: the heading would tick a machine the user was not pointing at, into a set
they are about to delete.

Three of the seven entries are about one machine and are drawn only for one. A
terminal, a file-transfer session and a form each have no reading over six, so
they are collapsed rather than refused. The other four read better for a count
than without one — it is the reason the set exists — and each of them says
afterwards how many hosts it wrote and how many it left alone. Skipping beats
refusing the whole run: a selection of eleven with one read-only row would
otherwise do nothing at all and then report about the wrong ten.

Copy to vault and Duplicate are new, and the difference between them is what each
can safely carry. A copy crosses a key boundary, so it drops the group and the
tags exactly as a move does — both are items of the vault being left, and a host
arriving with either would point at something the destination does not contain,
resolvable on the machine that sent it and dangling for everybody else. A
duplicate stays in the same keychain, so everything it points at is still there
and it keeps both. Change group is the write dragging a card onto a group already
makes on the desktop, run over a selection; it refuses one spanning two keychains
rather than half-filing it, which is the refusal a drop across that boundary
already makes one host at a time.

Connect via SFTP is the one action that leaves the vault. Which machine is a
decrypted item and so is this object's business; the screen it leads to and the
transfers view model behind it are the shell's — so it is an event, on the same
division SessionOpened already draws for a shell. The host is re-found in that
screen's own copy of the list, because the picker binds to rows in that copy and
handing it the vault's object would select nothing.

What is left of the card is the password box, and only because it had nowhere
else to go: a host that authenticates with a typed password cannot be reached by
a tap alone. That tap now raises a sheet rather than the bar, and the difference
is that a sheet is up only while a question is on screen — the bar was raised by
a long press and stayed, so it was a password box sitting over the list whether or
not anything was being asked. Dismissing it empties the box, which is not tidiness
either: a secret left behind would satisfy the emptiness check that decides
whether to raise the sheet at all, so the next tap would dial with somebody else's
password.

The pencil moving into that bar takes the host editor with it. It was a card in
the list's own row, under the search box and the sync line — twenty controls
sharing a screen with two rows of chrome about the list it had replaced. It is a
page now, and PhoneShell stands all four of its rows down for it, which is what
"opens with all the options" means at 360dp. That needed a second subscription in
that control: two of its flags are questions about the vault rather than about the
shell, and the shell does not forward the vault's notifications.

The ticks are held as entity ids rather than as rows, and written back onto the
rows after every reload. Every row object in the list is replaced on every filter
keystroke and every synchronisation pass, so a set of rows would empty itself once
a minute under somebody choosing what to do with eleven machines. Ids that no
longer resolve are dropped, so a colleague's deletion arriving mid-selection
leaves a count that matches what is on screen.

One caller had to change with it. ConnectToRecent opened the pane about a host,
which was the desktop's drawer and the phone's card; the phone's answer is now a
tick, and nothing on that list means "selected" any more — so arriving with the
host merely selected would be arriving at a screen with nothing to press. Both are
raised together, and the one the head in front of the user does not draw is inert.
2026-08-06 09:15:37 +02:00
jaap-jan 69858f82d1 Merge branch 'claude/vault-key-sync-sharing-d098aa'
ci / android head (push) Successful in 3m21s
ci / desktop nightly (push) Successful in 41s
ci / build and test (push) Successful in 2m0s
ci / api image (push) Successful in 33s
2026-08-06 07:39:32 +02:00
jaap-jan 509a7c34f5 Merge branch 'claude/snippets-vault-sharing-470476' 2026-08-06 07:39:25 +02:00
jaap-jan 185790fb14 Let a key move to another vault, and ask whether it goes with the host
Keys sync and keys are shared: SshKey is in the sync registry on both sides, the
material rides in the sealed payload, and every generation of the vault key is
wrapped to a new member. What was missing was the way in. Hosts and groups could
move between vaults and keychain items could not, so a key typed into a personal
vault before the team existed stayed there for good — and moving a host into the
team's vault left it authenticating with something nobody else in that vault can
read. The code said so and could do nothing about it: "the answer is usually to
put a copy of that key in the destination vault", which meant pasting the private
half into a second item and deleting the first. A private key on a clipboard, and
two items nobody can tell apart afterwards.

MoveAsync already existed on the generic repository and is now exposed for keys
and passwords as it is for hosts and groups. What had to be built around it is the
re-aim. An item re-sealed under another vault's key lands with an id of that
vault's making, so every host bound to the old one and every group lending it as a
default is left naming a tombstone — and a host bound to something its vault no
longer holds refuses to connect rather than falling back to a typed password. A
move without the re-aim would look like a success and break every machine on that
key. It runs over every vault this session can write to, because a binding
resolves across all of them, and it counts what it could not rewrite: an item from
a newer client, or one in a vault this account may only read. Those are said in
the sentence afterwards rather than swallowed.

The host's move asks the question rather than deciding it. A binding resolves
across vaults, so the moved host goes on working for the person who moved it
whichever way this is answered; it is the colleagues they have just joined who
hold one vault's key and cannot connect with a host whose key stayed behind.
Unticked, and it stays that way on purpose: moving a key into a team's vault hands
it to everybody holding that key, and this design does not default anybody into a
disclosure. Under the box is the count of everything else that authenticates with
that key, because a key twenty machines use is a different decision from one
nothing else touches, and neither number is visible from the panel otherwise. The
question is answered against the vault in the picker, so choosing a different
destination re-asks it and a key already in the destination offers nothing.

One thing fixed on the way. A host that inherited its key from its group arrived
in the destination naming nothing at all — the group belongs to the vault it left
— so a machine that connected before the move refused after it, with no sentence
anywhere saying why. The resolved binding is now written onto the host as it
crosses, and the stranded-binding warning reads the resolved binding too, which is
the case where somebody is least likely to know a key is involved.

MOVE is on both heads, for keys and passwords only: a tag, a bucket and a pin are
read from the active vault alone, so "another vault" is not a question any of them
has. Four tests cover the move and its re-aim, the host's move with the key
brought and without it, and the inherited binding.
2026-08-06 07:39:15 +02:00
jaap-jan 3d9ed03b09 Let a snippet be shared to a vault, the way a host already can
A snippet was a first-class vault item everywhere except where it mattered: the
crypto, the sync, the server table and every registry already treated it exactly
as they treat a host, and the screen read it out of the active vault alone. So
the one command a team most obviously wants to hold in common — the incantation
somebody worked out once and everybody else retypes — was the only item kind that
could not leave the machine that wrote it.

The read is the half that had to come first, and it is why this is not simply a
MoveAsync. ReloadSnippetsAsync now lists every readable vault rather than the
active one, in the shape ReloadHostsAsync and ReloadKeysAsync already use: the
vault new items go into first, then by vault name, then by label, with a badge on
the row only where there is more than one vault to tell apart. Without that, a
snippet moved into a team vault would have disappeared from the very screen that
moved it, and one a colleague wrote there would never have arrived at all —
sharing would have looked like losing.

Three writes were pinned to the active vault and each one broke differently once
the list spanned several. The delete tombstoned in the wrong vault, which
tombstones nothing and leaves the snippet on screen. The save is the bad one: an
update sent to the active vault creates a second snippet there and leaves the
team original untouched, so the person editing sees their fix and nobody else
ever does. That is a fork with no symptom, which is why the vault is now a
parameter and the screen latches it when the editor opens — the chosen vault for
a new snippet, the row own vault for an existing one — rather than reading it
back off a selection that can move under a half-typed form. VaultViewModel has
carried editingHostVaultId for the same reason since hosts crossed vaults.

Two controls rather than one, and that is the same line the host pane draws. The
editor asks which vault a new snippet is filed into; MOVE re-seals an existing one
under another key and tombstones the first. Putting the second inside the first
would let somebody correcting a typo hand a command to a team by leaving a picker
where they found it, so the picker is not drawn for an existing snippet at all.
Both live on SnippetsViewModel rather than VaultViewModel because this screen owns
its editor, unlike the host drawer; the writing they ask for is still the vault.

A snippet crosses whole, which is the one way this is simpler than the host it
copies. A host leaves its group and its tags behind because both are items of the
vault it came from and would dangle for everybody in the destination. A snippet is
a label, a command and a note, and none of them points at anything — so there is
nothing to strip, nothing to report as left behind, and what the copy says instead
is the thing that is actually at stake: who can read the command afterwards. For a
command that may carry a hostname or a path, that is the whole decision.

Two judgement calls worth finding later. A hidden vault now hides its snippets,
filtered in the screen projection rather than in VaultViewModel.Snippets, which is
the rule keys and passwords already follow: the list stays whole so nothing that
resolves against it breaks, and the projection is what a preference about reading
gets to change. And the nav rail count is left spanning vaults unfiltered, because
Vault.Hosts.Count beside it is unfiltered too — filtering one of the four would
make the rail disagree with itself.

Four flow tests in VaultSharingTests, beside the host ones they mirror: the move
re-seals with a new id and carries the runs-on-insert flag across, the move with
nowhere to go refuses rather than opening an empty picker, the editor files into
the vault chosen on it, and the edit of a shared snippet goes back to its own
vault instead of forking. That last one is the regression the latch exists for and
the only one whose absence has no visible symptom. Plus a layout test with the
move panel open, since that paragraph wraps in a 300-pixel column and the desktop
pane it lands in is measured.

The whole suite passes: 1660 tests, none failing.
2026-08-06 07:39:03 +02:00
jaap-jan cddfeb1f55 Keep the phone's nav under Connections when nothing is running
The chrome stands down for a shell, and it was standing down for the whole
terminal surface. Those parted company when that surface gained a connect page:
with no tabs open it draws a box, a CONNECT button and the machines connected to
before, which is a page in everything but which enum it is in. A third of the
display is worth giving to a shell and is not worth giving to that. Worse, it is
the one screen somebody arrives at by closing their last tab — so the state the
collapsed bar was most likely to be seen in was the state where it left the
system back gesture as the only route to Hosts or Settings.

So RefreshChrome reads one more question. IsTerminalSurface with no tabs joins
the pages in both flags, which keeps the rail and the bar in step: above 600dp
the rail is the bar, and fixing only the narrow layout would leave an unfolded
device on the same screen with the same nothing. The vault header is deliberately
not part of it. The surface draws its own bar with back and the +, and a header
above that is the second row of chrome this head exists to avoid.

The Connections entry lights for the first time, on IsTerminalSurface. It was
left unbound on the argument that the bar was never drawn while that surface was
up, so a lit state was unreachable — that argument is now false, and the flag is
unambiguous on a control that is only drawn in two situations: false on every
page, true on the connect page, and never read while a shell is showing. A bar
sitting under a screen it does not point at is the entry looking broken instead.

Nothing here is testable on this head — the phone's rectangles have no coverage,
for the reasons Phase 8 of manual-checks records — so 11.7 gains the check that
the bar is there with Connections lit, and 11.1 keeps the one that it is gone
with a shell up, which is the half that pays for the arrangement.
2026-08-06 07:38:31 +02:00
jaap-jan 174ef7c420 Merge branch 'claude/android-release'
ci / android head (push) Successful in 3m13s
ci / desktop nightly (push) Successful in 41s
ci / api image (push) Successful in 23s
ci / build and test (push) Successful in 1m57s
2026-08-05 22:49:51 +02:00
jaap-jan af0e29a98b Give the desktop a nightly channel, the way the phone has one
ADR 0014 gave the phone a nightly and ADR 0013 rule 3 gave the desktop none, so
the two heads had different answers to the same question — how does somebody try
what is on main? — for no reason except the order the work happened in. This is
the desktop's answer: CI publishes a build from main on every push, and it
installs beside the release one rather than over it.

The phone gets its separation from the platform. Android refuses an update signed
by a different key, so its two channels cannot replace one another whatever
anybody does. Nothing refuses anything here: Velopack applies what its feed serves
and verifies no signature. So all of it is construction, and there are four
separations because each closes a different door.

A pack id each, so the two install in different directories and neither feed's
package can be applied to the other's install. A Velopack channel each —
win and win-nightly — so neither build ever reads the other's release index; the
name reaches the wire as releases.win-nightly.json, which is why the constant in
VelopackUpdateChannel and the argument in ci.yml have to agree or the channel
answers nothing forever with no error. A prerelease flag, so the release channel
cannot see the nightly even by accident. And a profile directory each, which is
the one that is easy to skip and would hurt most: the cache schema is migrated on
every launch, before unlock, so a shared profile means a nightly quietly upgrading
a database the release build then opens. Both are installed at once by design, so
that is an ordinary Tuesday rather than a corner case.

The prerelease flag turns out to be load-bearing across heads as well. The phone's
release channel reads releases/latest, which skips prereleases — so a desktop
nightly published as a stable release would become the newest release in this
repository and every phone on the release channel would start failing its check
against a release carrying no Android manifest.

Which build this is arrives as assembly metadata, the same mechanism and the same
reasoning as the Android head: the updater needs the string rather than a branch,
and a value baked into the assembly is one a crash report can be asked for. Three
things read it — the feed, the prerelease flag, and the profile — and one more
shows it: the titlebar says DodoSSH Nightly. Everything else that distinguishes
the two is somewhere nobody is looking while typing a passphrase into one of them.

The version needed a floor and it is applied to the whole build rather than to the
packaging. MinVer answers 0.0.0-alpha.0.N until the first v* tag and vpk refuses
anything below 0.0.1, so the job lifts the patch digit and keeps the height —
through MinVerVersionOverride, so the assemblies carry the same number the
installer does. Packing a version the assembly disagreed with would put one string
on the preferences screen and another in the feed, which is the screen somebody
reads when asked which nightly they are on.

Two things found by running it rather than reading it. -t:MinVer needs a restore
first, because the target arrives with the package and MSB4057 on a clean checkout
reads like a typo in the workflow rather than a missing restore; the release
script had the same gap and now restores before it reads. And vpk rejects an empty
--packVersion loudly, which is how a broken version handoff announces itself
rather than shipping a package called 1.0.0.

Rule 3 is untouched. The release channel still has no job, no token and no runner,
and the two channels cannot see each other. What a nightly costs is written where
somebody reads it before installing one: whoever can write a release here can put
a build on every nightly machine, which is fine for a build being tried and is not
fine for a build holding somebody's infrastructure credentials.

Verified by running the job's own steps against a clone in a Linux container:
DodoSSH.Desktop.Nightly-win-nightly-Setup.exe, and an index naming pack id
DodoSSH.Desktop.Nightly at 0.0.1-alpha.0.144. The upload itself is the one step
not exercised — it needs a real forge and a write token, and check 16.10 is what
walks the half no runner can.
2026-08-05 22:35:51 +02:00
jaap-jan 8591035170 Merge branch 'claude/pipeline-curl-not-found-97e70f'
ci / android head (push) Successful in 3m18s
ci / api image (push) Successful in 25s
ci / build and test (push) Successful in 1m56s
2026-08-05 22:31:42 +02:00
jaap-jan ca7fee2358 Start the confirmation Android hands back, so an update can install
Pressing INSTALL closed the application, installed nothing and said nothing. That
is two independent faults in one method, either of which breaks it on its own,
and they hid each other: the first kills the process before the second can be
observed, and the second is silent by construction.

The pending intent handed to commit was implicit — an action string with no
component behind it. A mutable pending intent may not wrap one of those from API
34, and this head targets 36, so every current phone threw IllegalArgumentException
before commit was reached. Nothing caught it, so it left the command handler,
passed the dispatcher and took the process with it. That is the closing.

Below 34, where it did not throw, it still installed nothing. An application
holding REQUEST_INSTALL_PACKAGES rather than the privileged INSTALL_PACKAGES gets
no verdict back from a commit: what the platform answers first is
STATUS_PENDING_USER_ACTION, carrying the activity that draws the dialogue in
EXTRA_INTENT for the application to start. Android does not draw it on its own.
The comment here asserted the opposite — that a pending intent is required whether
or not anything listens, and that nothing needed to — so no receiver was ever
written, and the session was written, committed and left staged forever.

So there is a receiver now, not exported because the only sender is this
application's own commit, and the intent naming it is explicit, which is the same
change that stops the throw. Sessions are abandoned when anything fails, since one
created and neither committed nor abandoned stays staged against a per-application
cap — a repeating fault would have started failing at CreateSession instead, which
is the same bug wearing a completely unrelated face.

The reporting is the part worth keeping even after the cause is gone. Where
applying ends the process an exception has nowhere to go; where it does not, which
is this head's whole shape, it goes out through the dispatcher. RestartNowAsync now
answers the way CheckNowAsync already did, and the regression test asserts the
absence of a throw rather than the presence of one.

ADR 0014 rule 6 gets the correction in place: "asks Android to ask" is one step
longer than it reads. Check 17.5 needed no rewording — it asks for the installer
appearing by name, which is exactly the thing that never happened — so what it
gets instead is the two symptoms named, because both present as a dead button. It
is the only thing in the project that can catch either, and it plainly was never
run against a real pair of builds.

Note for whoever takes the next nightly: a broken updater cannot install its own
fix. The phone is running the code this commit replaces, so the first build
carrying it has to be sideloaded by hand; the ones after that install normally.

Compile-verified and manifest-verified — the receiver reaches the generated
manifest — and 321 tests pass. Not run on a device, which is what 17.5 is for.
2026-08-05 22:31:33 +02:00
jaap-jan b4619db8d2 Merge branch 'claude/android-release'
ci / android head (push) Successful in 3m24s
ci / api image (push) Successful in 22s
ci / build and test (push) Successful in 2m4s
2026-08-05 21:57:05 +02:00
jaap-jan 3d3d0bc95f Package the Windows client in CI, on the runner that could not
The build job published a win-x64 tree and stopped there, so the half of a
release that fails in ways a compile cannot see was proved by nobody until a
person was midway through cutting one. It now packs as well: vpk opens the
published binaries and verifies the main executable really calls
VelopackApp.Build().Run(), which is the check worth having — a refactor that
drops that call compiles, tests green, and produces an application that silently
never updates itself.

The file said this was impossible on Linux, and also said it was fine, in
comments forty lines apart. The claim that vpk needs Windows tooling to stamp the
Setup.exe stub is the one that was wrong: vpk cross-compiles when told to, and
the telling is a bracketed directive before the verb rather than a flag. Plain
`vpk pack --runtime win-x64` on a Linux host refuses outright and says so in the
message that names the fix. `[win]` must be quoted, or the shell reads it as a
glob matching any one of w, i and n. Only signing needs Windows, and nothing here
is signed yet.

Fixing that does not move ADR 0013 rule 3 an inch, which is why the two reasons
were recorded separately in the first place. What may not live on a runner is the
token, not the build: Velopack clients apply what their feed serves without
verifying a signature, so whoever can write a release can ship an update every
install runs. The packages go to RUNNER_TEMP and die with the job. They are not
offered as workflow artefacts either — an installer nobody has run should not sit
somewhere that invites passing it on.

Written out as shell rather than by calling scripts/release-windows.ps1. That
script is a person's procedure and holds things a runner must not have and must
not skip: it refuses a dirty tree, insists HEAD is tagged, downloads the previous
release for deltas, and asks for the forge token. Calling it would mean either
weakening it with CI switches or having CI satisfy conditions that only make
sense at a desk. The constants the two now share — pack id, title, authors,
channel, icon — are a contract with VelopackUpdateChannel and with every
installed client, and both sides say so.

Two things fell out of running the steps rather than reading them, and both were
in code nothing had ever executed:

dotnet msbuild -getProperty:Version answers 1.0.0. Without a target named it
evaluates the project and runs nothing, and MinVer computes inside a target — so
the read comes back as the SDK default on a full checkout with every tag present.
That line is the tag check in this file, which is `if:` a tag ref, and there are
no tags yet: the first release ever cut would have been refused by its own guard,
which would then have blamed fetch-depth. release-windows.ps1 had the same line
and would have demanded HEAD be tagged v1.0.0. Both now pass -t:MinVer.

And MinVer answers 0.0.0-alpha.0.N until that first tag exists, which vpk rejects
outright as below 0.0.1 — so packing the true version could not have worked on
any build made today. The patch digit is lifted for the throwaway package only.
The release script gets no such floor and must not: its version is the one users
compare against, and there the refusal is the right outcome.

Verified by extracting both steps from this file and running them against a real
clone in a dotnet SDK container: Setup.exe, the portable zip, the .nupkg and
releases.win.json, from a machine that is not Windows.
2026-08-05 21:56:45 +02:00
367 changed files with 71715 additions and 7402 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
+708 -22
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
@@ -147,13 +159,25 @@ jobs:
# green, the package is cut, and the symptom arrives weeks later as clients that never update.
#
# -getProperty evaluates without building, so this costs a second and runs before the build.
#
# ◆ -t:MinVer IS NOT OPTIONAL, AND WITHOUT IT THIS STEP FAILED EVERY TAG BUILD.
#
# -getProperty on its own evaluates the project and runs no targets, and MinVer sets Version
# from inside a target — so what came back was the SDK's default 1.0.0, for any tag, on any
# commit, in a clean checkout with every tag present. The step would have failed the first
# release ever cut and blamed fetch-depth for it, which is the one explanation that is not it.
# Naming a target makes -getProperty report the value after that target has run.
#
# Never exercised, because no v* tag exists yet: this whole step is `if:` a tag ref. It is the
# shape of bug a guard is prone to — the guard is the thing nothing tests, and it only speaks
# on the one build nobody is watching, which is the reason this step exists at all.
- name: the tag and the version agree
if: startsWith(github.ref, 'refs/tags/v')
run: |
set -euo pipefail
tag="${GITHUB_REF#refs/tags/v}"
declared="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
-getProperty:Version -nologo | tr -d '[:space:]')"
-getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
if [ "$tag" != "$declared" ]; then
echo "The tag says v$tag and MinVer computed $declared." >&2
@@ -208,14 +232,18 @@ 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
# failure mode is a restore graph or a native asset that resolves for net10.0 and not for
# net10.0/win-x64, which nobody would see until a person was halfway through cutting a release
# on a Windows machine. vpk can pack a Windows package from Linux; only signing needs Windows,
# and this repository signs nothing yet, so proving the publish here is worth the minutes.
# on a Windows machine.
#
# RestoreLockedMode=false for this command only, and it is not a loosened gate. The committed
# lock files are deliberately RID-free: declaring win-x64 on the desktop head writes a
@@ -224,20 +252,281 @@ jobs:
# under locked mode, so the image job would fail NU1004. The gate is the locked solution
# restore at the top of this job, which is unchanged.
#
# It rewrites the lock files as it goes; nothing after this step reads them, and the runner's
# It rewrites the lock files as it goes; nothing after it reads them, and the runner's
# checkout is thrown away. The release script does the same thing and puts them back, because
# there the tree is somebody's working copy.
#
# After the tests rather than before them, so a red suite does not first spend a hundred
# megabytes pulling a win-x64 runtime pack. main and tags only, for the same reason: a break
# found by the person about to release is found early enough.
- name: the windows publish still resolves
#
# The output directory is read by the packaging step below, so the two names have to agree.
- name: publish the windows desktop client
if: github.event_name != 'pull_request'
run: >
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
--configuration Release --runtime win-x64 --self-contained true
-p:RestoreLockedMode=false
--output "$RUNNER_TEMP/win-x64-check"
--output "$RUNNER_TEMP/win-x64"
# ◆ THE WINDOWS INSTALLER IS BUILT HERE, ON LINUX, AND IS DELIBERATELY THROWN AWAY.
#
# Packaging rather than only publishing, for the same reason the android job packages an APK
# nobody installs: the failures a release is most exposed to are the ones only the packager
# finds. vpk opens the published binaries and verifies that the main executable really calls
# VelopackApp.Build().Run() — so a refactor that drops that call, which compiles, tests green
# and produces an application that silently never updates itself, fails here instead of
# shipping. A publish alone cannot see that, and the person who would otherwise see it first is
# the one midway through a release.
#
# ◆ NOT WITH scripts/release-windows.ps1. That script is a person's release procedure and holds
# things a runner must not have and must not skip — it refuses a dirty tree, insists HEAD is
# tagged, downloads the previous release to build deltas against, and in its second phase asks
# for the forge token. Calling it from here would mean either weakening it with CI switches or
# having CI meet conditions that only make sense at a desk. So the packaging arguments below
# are written out again in shell, and the duplication is accepted and stated: the pack id, the
# title, the authors, the channel and the icon are a contract with VelopackUpdateChannel and
# with every installed client. Change one, change the other.
#
# ◆ AND CROSS-COMPILING NEEDS THE OS DIRECTIVE, WHICH IS EASY TO MISS.
#
# vpk picks its target from the host: on Linux, plain `vpk pack` builds a Linux release and
# refuses a Windows RID outright — "the target rid must be Linux (actually was Windows)". The
# bracketed [win] before the verb is what turns it into a cross-compile, and it must be quoted
# or the shell reads it as a glob matching any one of w, i and n. With it, the runner writes
# DodoSSH.Desktop-win-Setup.exe, the portable zip, the full .nupkg and releases.win.json,
# exactly as a Windows machine does. Only signing needs Windows tooling, and nothing here is
# signed — see ADR 0013 rule 8 for the trigger that changes.
#
# ◆ NOTHING IS UPLOADED, AND NOTHING IS EVEN OFFERED FOR DOWNLOAD.
#
# The output goes to RUNNER_TEMP and dies with the job. That is the whole point rather than an
# omission: ADR 0013 rule 3 puts the capability to ship somebody a build on a machine which is
# not a runner, because Velopack clients apply what their feed serves without verifying a
# signature. Publishing a CI-built package as a workflow artefact would not cross that line by
# itself, and it would put an installer nobody has run in a place that invites passing it on —
# so it is not done either. What reaches users is built, installed and walked through Phase 16
# of docs/manual-checks.md by a person first.
- name: package the windows desktop client
id: winpack
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
# vpk comes from the tool manifest, at the version committed there, which is the same one
# the release script uses. Restored here rather than in a step of its own because this is
# the only job that needs a tool at all.
dotnet tool restore
# The same value the release script packs with, read the same way. A version vpk disagrees
# with the assemblies about is a client that compares wrongly, so it is read from MSBuild
# rather than reconstructed from the ref — a main build has no tag to reconstruct from
# anyway, and MinVer's answer there carries the prerelease height that tells two of them
# apart.
#
# -t:MinVer for the reason spelled out on the tag check above: without a target named,
# -getProperty answers 1.0.0 and every package would carry the same version forever, which
# Velopack reads as "no update available".
version="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
-getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
if [ -z "$version" ]; then
echo "Could not read the version from MSBuild." >&2
exit 1
fi
# ◆ AND A FLOOR UNDER IT, BECAUSE MINVER'S PRE-FIRST-TAG ANSWER IS ONE vpk REFUSES.
#
# Until a v* tag exists MinVer answers 0.0.0-alpha.0.N — correct, documented, and rejected
# outright by vpk: "Invalid package version '0.0.0-alpha.0.143', it must be >= 0.0.1". So
# today, and on every main build until the first release, packing the true version cannot
# work at all.
#
# The patch digit is lifted and the prerelease part kept, so the stand-in still sorts below
# anything real and still differs between commits. Sound only because this package is
# thrown away: what is being proved here is that the desktop client packages, and no client
# will ever see this number. The release script is not given a floor and must not be — its
# version is the one users compare against, and if MinVer ever answered 0.0.0 there the
# right outcome is the refusal.
packVersion="$version"
case "$packVersion" in
0.0.0*)
packVersion="0.0.1${packVersion#0.0.0}"
echo "MinVer says $version, which vpk will not pack; packaging as $packVersion."
echo "This disappears the moment a v* tag exists, and never reaches a release."
;;
esac
releases="$RUNNER_TEMP/win-releases"
# --skip-updates because vpk otherwise asks nuget.org whether a newer vpk exists on every
# run, which is a network call whose answer this build must not act on: the version that
# packs is the one in the tool manifest.
#
# No `vpk download`, where the release script has one. That fetches the previous release so
# deltas can be built against it, which needs the feed and produces an artefact nobody
# applies. A full package proves the packaging.
dotnet vpk '[win]' pack \
--skip-updates \
--packId DodoSSH.Desktop \
--packVersion "$packVersion" \
--packDir "$RUNNER_TEMP/win-x64" \
--packTitle DodoSSH \
--packAuthors DodoTech \
--mainExe DodoSSH.exe \
--icon src/DodoSSH.Client.App/Assets/dodossh.ico \
--runtime win-x64 \
--channel win \
--outputDir "$releases"
# Asked for rather than assumed. vpk fails loudly and this costs a line, but the artefact
# whose existence is the entire question — a Windows setup stub built on a machine that is
# not Windows — is worth naming rather than inferring from an exit code.
setup="$releases/DodoSSH.Desktop-win-Setup.exe"
if [ ! -s "$setup" ]; then
echo "vpk reported success and there is no setup executable at $setup." >&2
ls -la "$releases" >&2 || true
exit 1
fi
ls -la "$releases"
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
# Handed to the macOS step below rather than worked out again there. The floor logic above
# is thirty lines of reasoning about MinVer's pre-first-tag answer, and a second copy of it
# is a second thing to keep in step — while two desktop packages built from one commit
# carrying different version numbers is precisely the confusion this file spends that
# reasoning to avoid.
echo "packVersion=$packVersion" >> "$GITHUB_OUTPUT"
# ◆ AND THE macOS BUNDLE IS BUILT HERE, ON LINUX, AND IS ALSO THROWN AWAY.
#
# Same argument as the Windows step above, one platform along: the failures a release is most
# exposed to are the ones only the packager finds, and the person who would otherwise find them
# is the one midway through a release on the one Mac that can cut one.
#
# What this catches that the Windows step cannot: the osx-arm64 restore graph. A native package
# that resolves for win-x64 and has no osx-arm64 asset — libsodium and SkiaSharp both ship per
# RID — fails here, on every main build, rather than at the first `dotnet publish` of a release
# nobody can retry without a Mac.
#
# ◆ bundle, NOT pack, AND THE DIFFERENCE IS NOT A CHOICE.
#
# `vpk [osx]` cross-compiling from a non-Mac offers exactly one packaging verb: bundle, which
# builds the .app. There is no `[osx] pack` off a Mac, and that is correct rather than a gap —
# pack signs with codesign, submits to Apple with notarytool and staples the ticket, all of
# which is Apple tooling that exists on no other platform. So this proves the bundle and stops
# where the platform does.
#
# No --plist and no --icon either, deliberately. Both are proved by scripts/release-macos.sh on
# the machine that can also check the result; passing a rendered plist here would mean copying
# the substitution out of that script to no end, since nothing looks at what this produces.
#
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
#
# RUNNER_TEMP, dying with the job. ADR 0013 rule 3 puts the capability to ship somebody a build
# on a machine which is not a runner, and an unsigned .app is additionally something no Mac
# would open — so publishing it would be handing out a file whose only possible use is confusion.
- name: publish and bundle the macos desktop client
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
# RestoreLockedMode=false for the RID, exactly as the win-x64 publish above does — see the
# long note there for why the committed lock files are deliberately RID-free. This runner's
# 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 osx-arm64 --self-contained true \
-p:RestoreLockedMode=false \
--output "$RUNNER_TEMP/osx-arm64"
# The apphost has no extension on macOS, so this is `DodoSSH` and not `DodoSSH.exe`. Named
# rather than globbed, because a publish that produced no apphost at all would otherwise
# bundle happily and produce an .app that launches nothing.
if [ ! -s "$RUNNER_TEMP/osx-arm64/DodoSSH" ]; then
echo "The osx-arm64 publish produced no apphost." >&2
ls -la "$RUNNER_TEMP/osx-arm64" >&2 || true
exit 1
fi
bundles="$RUNNER_TEMP/osx-bundle"
# The quotes around [osx] are load-bearing, exactly as they are on '[win]' above: unquoted,
# the shell reads it as a glob matching any one of o, s and x.
dotnet vpk '[osx]' bundle \
--skip-updates \
--packId DodoSSH.Desktop \
--packVersion '${{ steps.winpack.outputs.packVersion }}' \
--packDir "$RUNNER_TEMP/osx-arm64" \
--packTitle DodoSSH \
--packAuthors DodoTech \
--mainExe DodoSSH \
--bundleId dev.dodotech.dodossh \
--runtime osx-arm64 \
--channel osx \
--outputDir "$bundles"
# Asked for rather than inferred from an exit code, for the reason the Windows step gives.
# The Info.plist is the specific thing worth naming: a bundle missing it is a directory
# macOS will not treat as an application at all, and it is the one part of the .app that
# vpk composes rather than copies.
app="$bundles/DodoSSH.Desktop.app"
if [ ! -s "$app/Contents/Info.plist" ]; then
echo "vpk reported success and there is no Info.plist at $app/Contents/Info.plist." >&2
find "$bundles" -maxdepth 3 >&2 || true
exit 1
fi
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
@@ -284,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.
@@ -643,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
@@ -652,14 +946,399 @@ jobs:
echo "Published nightly $VERSION with$names"
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.
needs: [build]
runs-on: [linux]
# main only, and the whole job rather than its last step. A v* tag belongs to the release channel,
# which no runner may publish — ADR 0013 rule 3 — and the desktop head is already built and packed
# on tags by the build job above, so there is nothing here a tag build would gain.
if: github.ref == 'refs/heads/main'
# Writes the rolling nightly release at the end of the job. Job-scoped, so no other job in this file
# gains it; see the publish step for what the capability is and why this channel may hold it.
permissions:
contents: write
steps:
# Duplicated from the build job; see the comment there for why it cannot be factored out. There
# are four copies now, and any change has to be made in all four.
- name: ensure node and git
run: |
set -eu
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
missing=""
command -v node >/dev/null 2>&1 || missing="$missing nodejs"
command -v git >/dev/null 2>&1 || missing="$missing git"
if [ -z "$missing" ]; then
echo "node $(node --version), git $(git --version)"
exit 0
fi
echo "Installing:$missing"
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends $missing
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache $missing
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y $missing
else
echo "No apt-get, apk or dnf here, so node cannot be installed from inside the" >&2
echo "job. Point the runner's container.image at something that ships node." >&2
exit 1
fi
echo "node $(node --version), git $(git --version)"
major="$(node --version | sed 's/^v//; s/\..*//')"
if [ "$major" -lt 20 ]; then
echo "::warning::node $major is older than the runtime these actions target;" \
"give the runner an image with node 20 or newer if actions misbehave."
fi
# fetch-depth 0 for the reason the other three jobs give, and here it decides what gets published:
# MinVer's answer is this build's version and the number a nightly client compares against.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
cache-dependency-path: '**/packages.lock.json'
# Before the version is read, and that ordering is not tidiness. MinVer arrives as a package, so its
# target does not exist until a restore has written obj/*.nuget.g.targets — and `-t:MinVer` on an
# unrestored project fails MSB4057 "the target does not exist", which reads like a typo in this file
# rather than like a missing restore. The build job's tag check is safe because it runs after that
# job's own restore; this job has none, so it needs this one.
#
# Locked, like the solution restore in the build job. The RID-specific restore the publish needs is
# unlocked and asks for that itself, exactly as the build job's publish does.
- name: restore
run: dotnet restore src/DodoSSH.Client.App/DodoSSH.Client.App.csproj --locked-mode
# ◆ THE VERSION IS DECIDED ONCE HERE AND THEN FORCED ON EVERYTHING.
#
# MinVer's own answer is not usable as it stands: until a v* tag exists it is 0.0.0-alpha.0.N, and
# vpk refuses to pack anything below 0.0.1. The floor is applied to the *whole build* rather than to
# the packaging alone, through MinVerVersionOverride, and that is the part worth understanding.
#
# Packing a version the assemblies disagree with would put one number in the installer and another
# on the preferences screen — the screen a person reads when asked which nightly they are on, and
# the number they would then quote into an issue that nobody can match to a build. MinVer sets both
# Version and InformationalVersion from the override, so the two cannot drift.
#
# Monotonic across the boundary, which is what a feed needs: heights keep rising within a floored
# version, and the first real tag moves the whole number up past every floored one.
- name: the nightly version
id: version
run: |
set -euo pipefail
# -t:MinVer for the reason the tag check in the build job spells out: without a target named,
# -getProperty answers the SDK default and every nightly would claim the same version forever.
version="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
-getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
if [ -z "$version" ]; then
echo "Could not read the version from MSBuild." >&2
exit 1
fi
case "$version" in
0.0.0*)
floored="0.0.1${version#0.0.0}"
echo "MinVer says $version, which vpk will not pack; this nightly is $floored."
version="$floored"
;;
esac
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "$version"
# DodoChannel=nightly is what makes this a different application rather than the same one built
# again: it puts the channel in the assembly, which is where DesktopChannel reads it to pick the
# feed to poll, whether to accept prereleases, and which profile directory to keep a cache in.
#
# RestoreLockedMode=false for the RID, exactly as the build job's publish does — see the long note
# there. This checkout is thrown away, so the lock files it rewrites go nowhere.
- name: publish the nightly
env:
VERSION: ${{ steps.version.outputs.version }}
run: >
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
--configuration Release --runtime win-x64 --self-contained true
-p:RestoreLockedMode=false
-p:DodoChannel=nightly
-p:MinVerVersionOverride="$VERSION"
--output "$RUNNER_TEMP/nightly-win-x64"
# ◆ A DIFFERENT PACK ID, A DIFFERENT CHANNEL, A DIFFERENT TITLE. ALL THREE, AND NONE IS COSMETIC.
#
# packId decides where Velopack installs and what an installed client matches an update against, so
# DodoSSH.Desktop.Nightly is what makes this install *beside* the release build rather than over it,
# and what stops either feed's package being applied to the other's install.
#
# channel decides the name of the index file on the feed — releases.win-nightly.json — and it is a
# contract with VelopackUpdateChannel.NightlyChannel. Disagree on this word and the channel answers
# nothing, forever, with no error anywhere.
#
# title is what a person reads in the Start menu and in Add/Remove Programs, and it is the only one
# of the three they will ever see. Two entries both called DodoSSH would be the whole benefit of
# installing side by side, thrown away at the last step.
#
# No `vpk download` and so no deltas: this channel deletes its previous release on every push, so
# there would be nothing on the feed for a delta to be applied against. A nightly update is a full
# download, which is the honest cost of a rolling channel that keeps exactly one build.
- name: package the nightly
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
dotnet tool restore
dotnet vpk '[win]' pack \
--skip-updates \
--packId DodoSSH.Desktop.Nightly \
--packVersion "$VERSION" \
--packDir "$RUNNER_TEMP/nightly-win-x64" \
--packTitle 'DodoSSH Nightly' \
--packAuthors DodoTech \
--mainExe DodoSSH.exe \
--icon src/DodoSSH.Client.App/Assets/dodossh.ico \
--runtime win-x64 \
--channel win-nightly \
--outputDir "$RUNNER_TEMP/nightly-releases"
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
# from this feed and applies what it finds without verifying a signature. That is the same capability
# as a signing key reached through a different door, and ADR 0013 rule 3 keeps it off runners.
#
# It is acceptable here for the reason the android nightly gives, and only for that reason: this is
# not that channel. A nightly is a separate application with its own pack id, its own install
# directory and its own profile, and it cannot update the build anybody is trusting with their
# credentials — the release channel reads a different index and refuses prereleases, so it cannot
# even see this one. Anybody installing a nightly is trusting everyone who can write to this
# repository, which is a thing to know rather than a thing to discover; the README says so.
#
# ◆ DELETED AND RECREATED RATHER THAN ADDED TO.
#
# A rolling channel has to keep exactly one build, and every asset here is version-named, so merging
# would grow the release by a hundred and twenty megabytes per push until the forge said no. There is
# no atomic form of this in the API, so the shape with the fewest states is to remove both the
# release and its tag and make them again. The window where no nightly exists is a few seconds, and
# the client's answer to it is the same as to an unreachable forge: the timer swallows it and tries
# later; a pressed CHECK NOW says so.
- name: publish the nightly release
env:
FORGE: https://git.dodotech.cloud
REPO: DodoTech-Public/DodoSSH
TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
TAG: nightly-desktop
run: |
set -euo pipefail
if [ -z "${TOKEN:-}" ]; then
echo "No token, so the nightly was built and not published." >&2
echo "GITHUB_TOKEN is provided by the runner; an empty one means Actions is configured" >&2
echo "without it, and the job's contents: write permission is what asks for it." >&2
exit 1
fi
api="$FORGE/api/v1/repos/$REPO"
auth="Authorization: token $TOKEN"
# The first "id" with digits after it, for the reason the android job records at length: a
# greedy .* walks past the release's own id to the author's, which is -2, and yields nothing.
release_id() {
grep -oE '"id":[0-9]+' | head -1 | cut -d: -f2
}
existing="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG" 2>/dev/null || true)"
if [ -n "$existing" ]; then
id="$(printf '%s' "$existing" | release_id)"
if [ -n "$id" ]; then
echo "Removing the previous $TAG release $id"
curl -fsS -X DELETE -H "$auth" "$api/releases/$id" >/dev/null || true
else
echo "A $TAG release exists and its id could not be read:" >&2
printf '%s\n' "$existing" >&2
exit 1
fi
fi
curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true
# vpk rather than curl, unlike the android job above, and the difference is what is being
# uploaded. An APK is one file the client is told about by a manifest this repository writes; a
# Velopack release is a set of files plus an index whose format is Velopack's own. Writing that
# index by hand would be reimplementing the tool that is already here.
#
# --pre is load-bearing twice over. It keeps this out of the release channel, which refuses
# prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
# channel reads: a desktop nightly published as a stable release would become the newest release
# in this repository and every phone on the release channel would start failing its update check
# against a release carrying no android manifest.
dotnet vpk upload gitea \
--skip-updates \
--repoUrl "$FORGE/$REPO" \
--token "$TOKEN" \
--outputDir "$RUNNER_TEMP/nightly-releases" \
--channel win-nightly \
--tag "$TAG" \
--releaseName "Nightly desktop $VERSION" \
--targetCommitish "$GITHUB_SHA" \
--pre \
--publish
# Asked for back rather than assumed, and the android job's history is why: it once created a
# release, uploaded nothing, and reported success for every upload it never made. A nightly
# desktop feed that exists and carries no index is a client that checks, finds nothing, and
# reports itself up to date forever.
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
# 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
# ◆ 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
# inspects — it is the thing that gets deployed.
needs: [build]
runs-on: [linux]
# Gitea's own container registry, on the forge this workflow runs on, under the same owner as
# the repository - so the image is listed with the repository's packages rather than on a
# registry of its own. Lowercase, because an image reference may not contain capitals and
# the owner is DodoTech-Public.
#
# REGISTRY_USERNAME and REGISTRY_PASSWORD are a Gitea account and a token of its own with
# package read and write, allowed to publish for that owner. Not the job's built-in
# GITEA_TOKEN, tempting as it is here: the container registry does not accept an Actions
# task token and answers `unauthorized` at /v2/ (go-gitea/gitea#23642). Spelunk's workflow
# pushes the same way, with the same two secrets.
env:
REGISTRY: git.dodotech.cloud
IMAGE: dodotech-public/dodossh-api
steps:
# Duplicated from the build job; see the comment there for why it cannot be factored
# out. Any change here has to be made in all three.
@@ -785,9 +1464,6 @@ jobs:
# changes, and where setup-buildx-action starts earning its place.
- name: work out the tags
id: tags
env:
REGISTRY: registry-docker.dodotech.cloud
IMAGE: dodotech/dodossh-api
run: |
set -euo pipefail
repo="$REGISTRY/$IMAGE"
@@ -912,7 +1588,7 @@ jobs:
echo "username: ${#REGISTRY_USERNAME} characters; password: set"
printf '%s' "$REGISTRY_PASSWORD" \
| docker login registry-docker.dodotech.cloud \
| docker login "$REGISTRY" \
--username "$REGISTRY_USERNAME" --password-stdin
- name: push
@@ -930,20 +1606,30 @@ jobs:
# does not leave it behind.
- name: log out
if: always() && github.event_name != 'pull_request'
run: docker logout registry-docker.dodotech.cloud
run: docker logout "$REGISTRY"
# There is no job here that publishes the desktop client, and there is not going to be one. Two
# independent reasons, and both need saying because someone will fix one and think they are done.
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
# install directory and profile — and the distance between those two sentences is the whole design. What
# the build job does for the release channel is prove it still builds and packs; the upload is withheld.
#
# The smaller one is mechanical: vpk stamps and embeds the Setup.exe and Update.exe stubs with Windows
# tooling, and every job in this file is runs-on: [linux]. A Windows runner would answer that.
# ◆ ONE REASON, WHERE THIS ONCE CLAIMED TWO, AND THE SECOND WAS NOT TRUE.
#
# The larger one is that a Windows runner would not answer the other. Velopack clients fetch from the
# release feed and do not verify a package signature when they apply it, so whoever can write a release
# on this repository can publish an update that every installed client downloads and runs. That is the
# same capability as the signing key, reached through a different door — and docs/adr/0011 rule 1 puts
# that capability on a machine which is not a runner, because a workflow secret is held by everyone who
# can change a workflow file. See docs/adr/0013-desktop-distribution-and-updates.md.
# It used to say that vpk stamps and embeds the Setup.exe and Update.exe stubs with Windows tooling and
# so could not run on a Linux runner, and offered that as the smaller of two reasons. It was wrong, and
# worth recording as wrong because it is the kind of claim that discourages anybody from trying: vpk
# cross-compiles when told to, `vpk [win] pack` on this runner builds the setup stub, the portable zip
# and the .nupkg, and only signing needs Windows — which this repository does not do yet. That is what
# the packaging step now does on every main and tag build.
#
# The reason that stands is the one that was never mechanical. Velopack clients fetch from the release
# feed and do not verify a package signature when they apply it, so whoever can write a release on this
# repository can publish an update that every installed client downloads and runs. That is the same
# capability as the signing key, reached through a different door — and docs/adr/0011 rule 1 puts that
# capability on a machine which is not a runner, because a workflow secret is held by everyone who can
# change a workflow file. A Windows runner would never have answered it, which is why fixing the
# mechanical half changes nothing about the division of labour.
# See docs/adr/0013-desktop-distribution-and-updates.md.
#
# What cuts a release is scripts/release-windows.ps1, run by a person. What this file does is prove the
# thing still builds and packages, which is the same division of labour the android job above already
+8 -1
View File
@@ -87,9 +87,16 @@ Releases/
*.pfx
*.p12
*.snk
# Signing keystores, with one exception that is tracked deliberately. build/nightly.keystore is
# committed in the open on purpose — see ADR 0014 for why a key everybody holds is what makes the
# nightly channel safe to run in CI — and a file already tracked is unaffected by an ignore rule
# anyway. The negation is here so that reading this list does not suggest the tracked one is an
# oversight. The *release* keystore is a real secret under ADR 0011 rule 1 and must never land here.
*.keystore
*.jks
!build/nightly.keystore
appsettings.Local.json
appsettings.*.Local.json
.env
.env.*
!deploy/.env.example
secrets/
+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>
+299 -15
View File
@@ -44,7 +44,7 @@ Three consequences worth knowing before you read further:
The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with
[the E2EE trust model](docs/adr/0001-e2ee-trust-model.md).
The desktop client's interface was built from a design covering more product than exists yet — file
The desktop client's interface was built from a design covering more product than existed at the time — file
transfer, teams, saved snippets, port forwarding. Everything that design asked for and this build has not
got is written down in [`docs/design-import-gaps.md`](docs/design-import-gaps.md), with the layer each
piece would land in and what the interface shows in its place. Nothing was rendered with invented data to
@@ -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
@@ -80,6 +81,10 @@ docs/platform-flags.md what differs off Windows, and the gotchas that have c
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
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.
@@ -152,8 +157,129 @@ reinstalling asks for your passphrase rather than starting over. Use **Sign out*
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
this machine's device key from your account.
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
## Installing on macOS
A `.pkg` on the same release page, for Apple Silicon. Everything above about where a client may come from,
about the update check and about uninstalling applies unchanged; what differs is worth three short
paragraphs.
**It is signed and notarized, so there is no warning to click past.** That is not generosity — macOS refuses
to open an un-notarized download outright rather than warning about it, so unlike the Windows build there
was never an unsigned option. If you *do* see "cannot be opened because Apple cannot check it for malicious
software", the file did not come from the project's release page, and that is worth taking literally.
**Apple Silicon only for now.** An Intel package is a small amount of work and no one here has an Intel Mac
to check it on, and this project does not ship desktop builds nobody has run — see
[docs/manual-checks.md](docs/manual-checks.md). Under Rosetta the arm64 build will not run; there is no
graceful version of that, and the honest answer is that the platform is not covered yet.
**Touch ID can stand in for your passphrase**, on a Mac with a Secure Enclave. The key that unwraps your
device key is generated inside the enclave and never leaves it, and the enclave — not DodoSSH — is what
requires your fingerprint or login password before it will use it. Cancel the prompt and you get the
passphrase screen, always. The application lives at `/Applications/DodoSSH.Desktop.app` and your vault cache
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.
### The nightly desktop build
There is a second Windows build, published by CI from `main` on every push, on the `nightly-desktop`
release. It installs **beside** the release build rather than over it — its own entry called *DodoSSH
Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\DodoSSH.Nightly` — so trying it
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
and not one for a build holding your infrastructure credentials, which is the entire reason the two
channels exist and cannot see each other. The release build refuses prereleases and reads a different feed,
so nothing published here can ever reach it. Same arrangement, and same reasoning, as the phone's nightly —
[ADR 0014](docs/adr/0014-android-updates.md), and [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md)
decision 9.
## Running it
@@ -300,6 +426,90 @@ What is not here: transferring a directory, dragging between the panes, and rout
bastion — the last needs jump hosts the connection layer has not got. All three are in
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
### Forwarding ports
A host can carry **port forwards**, added under PORT FORWARDS in its editor, in the three kinds OpenSSH has:
**local** (`-L`, a port here reaching a machine the remote can see), **remote** (`-R`, a port on the remote
reaching back to one this machine can see) and **dynamic** (`-D`, a SOCKS proxy here whose connections leave
from the remote). They are saved in the host's encrypted payload, so they sync, merge and are shared with the
host, and they start with every terminal opened to it, on either head. The session sidebar lists them under
FORWARDS with the state of each, and the status bar counts the ones listening; on the phone the same list is a
sheet off the terminal's bar.
Three things about them are worth expecting rather than discovering.
**Everything listens on localhost, and there is no setting for it.** A forward is a field any member of a
shared keychain can fill in, and one bound to every interface would hand the remote's network to anybody who
can reach your machine for as long as the terminal is open. A remote forward asks the remote for its loopback
too; whether the remote honours that is its own `GatewayPorts`.
**A forward lives as long as its terminal.** It runs over that tab's connection, so closing the tab, losing
the connection or reconnecting stops it — a reconnect starts the host's forwards again on the new session. A
second tab to the same host runs the same forwards, and they fail there because the ports are taken; the row
says so, and the terminal is unaffected. Any row can be switched off and on while its session is up.
**No byte counts**, which the design draws: SSH.NET keeps none, so a number there would be invented. The
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
[The Android head](#the-android-head).
### Reaching a server through Spelunk
A server managed by Spelunk runs Spelunk's agent, which keeps a connection open to Spelunk. DodoSSH can use
that connection instead of dialling: tick **Connect through Spelunk** in the host editor and fill in
Spelunk's API address, the server's id in Spelunk, and your Spelunk API key. A connection then asks Spelunk
for a ticket, opens a WebSocket, and the agent connects to the SSH server on the machine's own loopback — so
the server needs no open SSH port at all. Shells and file transfers both go this way; the relay and Spelunk
are one choice, and ticking one unticks the other.
- **Fill in the API key once, under Settings → Security → Spelunk.** Give Spelunk's API address and paste
the key (`spelunk_…`, with the Admin role). It is saved as a *Spelunk key* in your personal vault —
encrypted, synced to your other devices, never shown again — together with the address, and **it is only
ever sent to that address**: a host that names the key with any other address is refused at connect. One
key serves every server in that Spelunk organisation; saving a key for an address that already has one
replaces it, and every host using it connects with the new key. The host editor offers saved keys first
and picks the one for the address you type. The API address must be `https://`, except for a Spelunk on
this machine.
- A key kept the older way, as a credential's password, still works and is still offered in the editor;
its password goes to whatever address the host names.
- **The hostname and port still matter**, though nothing dials them: the host key is pinned under them as it
is for every host, and the port is what the agent connects to on the server.
- **There is no falling back to a direct dial**, and a refusal is said in Spelunk's terms: a key Spelunk will
not take, a key whose role is too low, a server outside the key's organisation, an agent that is not
connected, an agent that could not reach the SSH port or is too old to open terminals.
**Sign in through Spelunk (no SSH key needed)**, the box under the key in the same card, lets Spelunk log
you in instead of an SSH key or password of the host's own. For each connection DodoSSH makes a throwaway
Ed25519 key pair in memory and sends the public half with the ticket request, which the API key authorises;
the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says
which account it logs in as (`root`). It is real SSH to the server's real host key, so shells, file
transfers and port forwards all work, and the key is gone with the connection — it is never stored,
synced or logged. The login and authentication choices are greyed out while it is ticked and saved as
nothing. The server needs OpenSSH's `sshd` installed (not running, and no open port); an agent too old
to sign in, or a server without `sshd`, is said so. Such a host is written at host schema version 10,
so a DodoSSH older than this shows it read-only.
**"Open in DodoSSH" in Spelunk** starts DodoSSH with a `dodossh://spelunk/connect?api=…&server=…&name=…&host=…`
link, or hands the link to the copy already running, and **connects straight away.** The link never
carries a credential. The hosts links make live in your personal vault, tagged `spelunk`:
- If your personal vault has a host tagged `spelunk` for that server on that Spelunk, DodoSSH connects to it.
- Otherwise, when you have saved a Spelunk key for that Spelunk under Settings → Security — or a saved host
already sends an API key there — DodoSSH saves a new host in your personal vault — named as the link
names it, at the link's address (or `spelunk-<server id>`), port 22, signing in through Spelunk with that
key, tagged `spelunk` (the tag is made if the vault has none) — and connects to it. It syncs like any host
you saved yourself.
- **A link from a Spelunk with no key** opens the host editor instead, filled in the same way with sign-in
ticked and the tag on, and you choose that Spelunk's API key once and save. Every later link from that
Spelunk connects straight away. A link can be sent by anything on this machine, so an API key is never
sent to an address you have not sent it to — saving the key under Security is how you say you have.
A host you set up by hand, or one in a team's vault, is left as it is: a link for its server adds a host of
its own. A link that arrives while the vault is locked waits for the unlock.
The Windows installer registers the scheme for your user and removes it on uninstall, the macOS bundle
declares it in its `Info.plist`, and the Linux package's desktop entry declares `x-scheme-handler/dodossh`.
The phone reaches Spelunk hosts the same way but does not answer links yet.
### Sharing a vault
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
@@ -407,8 +617,28 @@ The two vaults are encrypted under different keys, so a move is a re-seal into o
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
What a move cannot do is reach a machine that has already synced the host, which is the same limit
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
to" picker on the Keychain screen and cannot be moved yet.
everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the
Keychain screen and cannot be moved yet.
**The move asks whether the key comes too**, because that is the half a host's move could not settle on its
own. A binding resolves across every vault you can read, so the moved host goes on working for *you* either
way — but the people you have just shared it with hold one vault's key, and a host whose key stayed in your
personal vault is one they cannot connect with. The tick box beside the picker is unticked, and stays that
way on purpose: moving a key into a team's vault hands it to everybody who holds that key, and a disclosure
is chosen rather than defaulted into. Under it is the count of everything else that authenticates with that
key, which is what makes the answer decidable — a key twenty machines use is a different decision from one
nothing else touches. Left unticked, the sentence afterwards names the key that is now outside the
destination. A key the host only *inherits* from its group counts too, and is written onto the host on the
way across: the group stays behind, so a host that arrived naming nothing would authenticate with nothing.
**A key or a password can also be moved on its own** — MOVE beside EDIT and DELETE on the Keychain screen,
on both heads, for keys and passwords only. It is the same re-seal and tombstone, and it takes a new id in
the destination, so **everything that named it is re-aimed at where it went**: every host bound to it and
every group lending it as a default, across every vault you can write to. Without that the move would be a
deletion with extra steps, since a host bound to something its vault no longer holds refuses to connect
rather than falling back to a typed password. Anything that cannot be rewritten here — an item from a newer
client, or one in a vault you can only read — is left naming the old item and is counted in the sentence
afterwards. The panel says what points at the key before you press it, not after.
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
@@ -495,8 +725,9 @@ its host if it has one and back into the box if it does not.
**A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom
bar are collapsed while a shell is up, and a single 35-pixel row replaces them: back on the left, the
sessions as pills, and a `+` on the right offering the three connections this application can make — a
shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers
that menu first if it is open.
shell, a host's files over SFTP, or a bucket. Beside the `+`, for a tab whose host has port forwards, a `⇄`
opens them; see below. The system back gesture does what the arrow does, and lowers either sheet first if
one is open.
Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
screen exactly the width it was designed against.
@@ -564,10 +795,25 @@ What it does *not* yet do is use the extra width inside a screen: the host list
size. Two columns needs the row model to change — the list is headings and hosts in one sequence, and a
heading has to span — which is shared with the desktop and is its own piece of work.
**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than
leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so
every control on that screen would have had no effect. See
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
**Port forwarding runs here too, and is edited on the desktop.** A host's forwards start with every
terminal the phone opens to it, as they do on the desktop. The terminal's bar grows a `⇄` for a tab that has
any, carrying the number listening in green, or turning red when one could not start; it opens a sheet
listing the tab's forwards with their route, their state, the reason a failed one gave, and a tap on each to
switch it off or on — the desktop sidebar's FORWARDS section, over the same rows. The v2 design's whole
FORWARDING screen is not drawn, for the reason the desktop has none: a forward belongs to a host and runs
with a session, and both already have a place. What the phone has not got is an editor for them — its host
form has no PORT FORWARDS section — so they are added on the desktop and arrive with the host; the More
screen says so in a paragraph.
Forwards ride the **keep-alive** a shell already has. A forward runs over its tab's connection, so the
foreground service that holds a shell open in the background is holding its forwards open too, and nothing
else needs to: the service is never started for a forward alone, because there is never a listening forward
without a live shell. What forwards change is the notification, which counts them beside the shells —
`1 shell connected · 2 ports forwarded` — because a phone in a pocket holding ports open is something its
owner should be able to see. Local and SOCKS forwards listen on the phone's own loopback, which every app on
the phone can reach; that is what makes one useful here — a database client on the phone reaching a server
behind the remote — and it is the same exposure the desktop's localhost has to its own processes. See
[Forwarding ports](#forwarding-ports) and [`docs/design-import-gaps.md`](docs/design-import-gaps.md).
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
@@ -727,6 +973,13 @@ keychain plus a terminal — and the spike that gates all of it.
[Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
are consequences rather than choices.
*Relay done:* a host with "Connect through the server relay" ticked is reached through the deployment,
for a machine this one cannot dial. The server issues a single-use, thirty-second ticket for that host
alone, resolves its address and checks it against a deny list with no configuration escape — loopback,
link-local, metadata endpoints — and then pipes raw TCP; the SSH session still ends on your machine, so
the relay sees ciphertext. It is off by default (`Relay:Enabled`), and it only helps when the server can
reach what you cannot. See [ADR 0004](docs/adr/0004-relay-authorization.md).
*Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary
synced items — encrypted, merged and pushed like every other — and both are invisible until used: a
keychain with no groups draws the flat host list it always did. Two things about them are deliberate.
@@ -849,8 +1102,39 @@ keychain plus a terminal — and the spike that gates all of it.
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
[Installing on Windows](#installing-on-windows) for what a user sees.
Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the
first release aimed at strangers), and macOS and Linux packaging.
**The macOS half is built on the same machinery**, and signed from the start because Gatekeeper leaves no
choice: `scripts/release-macos.sh` publishes, signs every native library, notarizes with Apple and staples
the ticket before it will hand anything over, and refuses to upload until a person has installed it. The
device key is held in the Secure Enclave behind Touch ID. CI publishes `osx-arm64` and builds the `.app`
on every main build to prove it still packages, and uploads nothing. See
[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, 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"
+350
View File
@@ -0,0 +1,350 @@
# ==================================================================================================
# 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.
#
# %%u — RPM's spelling of a literal %u, the field code for "one URL" — and the MimeType line make this
# the handler for dodossh:// links, which is how Spelunk's "Open in DodoSSH" reaches the application:
# the desktop starts the binary with the link as its one argument, and Program.Main reads and checks
# it, or passes it to the instance already running. update-desktop-database in the post-install
# scriptlet is what makes xdg-open see the handler. Both channels declare it, so whichever is
# installed answers, and with both installed the desktop's own default decides.
mkdir -p %{buildroot}%{_datadir}/applications
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} %%u
Icon=%{dodo_bin}
Terminal=false
StartupNotify=true
StartupWMClass=DodoSSH
Categories=Network;RemoteAccess;System;
Keywords=SSH;SFTP;Terminal;Remote;Shell;
MimeType=x-scheme-handler/dodossh;
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.
+67
View File
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
What the hardened runtime has to be asked to relax before a .NET application will run under it.
The hardened runtime is not optional: notarization refuses a Developer ID submission without it,
and Gatekeeper refuses an un-notarized download. So every entitlement below is the price of being
distributable at all, and each one is a hole in a wall that is otherwise worth having. They are
listed one at a time, with what breaks without each, because the temptation when notarization
fails at eleven at night is to paste in a longer list from somewhere and stop thinking.
◆ WHAT IS DELIBERATELY NOT HERE.
com.apple.security.app-sandbox. Developer ID distribution outside the App Store does not require
the sandbox, and turning it on would break the product outright: the terminal's data plane is a
loopback WebSocket (see DodoSSH.Client.Terminal/TerminalDataPlane.cs), and a sandboxed process
needs com.apple.security.network.server to listen at all, plus network.client to reach any host
the user asks for. This is the same shape of decision as ruling out MSIX on Windows, which was
ruled out for the same loopback reason — docs/platform-flags.md.
com.apple.security.cs.debugger. Would let this process attach to others. Nothing here debugs
anything, and it is the entitlement most worth not having.
-->
<plist version="1.0">
<dict>
<!--
CoreCLR compiles IL to machine code at runtime and then executes the pages it just wrote. The
hardened runtime's default is that no page is both writable and executable, so without this the
process does not start — it dies during runtime initialisation, before any of this application's
code runs, which means before anything exists that could report it.
-->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!--
The broader form of the same permission, and it is needed as well as allow-jit rather than
instead of it. allow-jit covers pages mapped through the MAP_JIT convention; CoreCLR also
allocates executable memory outside that path — stubs, precode, and the write-xor-execute
fallback it uses when MAP_JIT is unavailable. With only the first, startup gets further and
still fails.
-->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!--
Library validation requires every loaded dylib to be signed by the same team as the main
binary. This bundle carries native libraries built by other people — libsodium, libSkiaSharp,
libHarfBuzzSharp, libe_sqlite3, libAvaloniaNative — and the release script signs each of them
with this Developer ID, which would in principle satisfy validation.
It is disabled anyway, and the reason is the updater. Velopack replaces the bundle in place and
relaunches it, and the process doing the replacing is not always signed by the same team as the
process being replaced during the changeover. Leaving validation on makes the failure mode of a
bad update "the application will not start", with no way to recover except a reinstall the user
would have to be told about through some other channel.
-->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!--
The runtime reads DYLD_ variables while resolving its own native dependencies, and Velopack's
update path sets them. Without this the hardened runtime strips them silently and the failure
surfaces later as a library that cannot be found, naming a file that is plainly present.
-->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
+144
View File
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
The Info.plist for the macOS bundle, with the version left as a placeholder.
◆ A TEMPLATE RATHER THAN A FILE, BECAUSE vpk COPIES A CUSTOM PLIST VERBATIM.
Measured, not assumed: `vpk [osx] bundle --plist` performs no substitution of any kind. It logs
"Bundle using provided Info.plist" and copies the bytes. That is also why it refuses --plist and
--bundleId together — with a plist supplied, every key is the caller's problem.
So a committed Info.plist would carry whatever version it was written with into every release
afterwards, and the failure is quiet in the worst way: Velopack's own release index would carry the
right version, the updater would compare correctly and update correctly, and only the About window,
Finder's Get Info panel and any crash report would claim the build was something else. Nobody
reads those on the day of a release. scripts/release-macos.sh substitutes @VERSION@ into a copy
and passes that.
◆ WHY A CUSTOM PLIST AT ALL, WHEN vpk WRITES A PERFECTLY GOOD ONE.
Three keys it does not write, each of which is a real defect without it:
CFBundleDisplayName The bundle on disk is DodoSSH.Desktop.app, because the pack id must not
be DodoSSH — see scripts/release-macos.sh for the directory collision
that rule prevents. On Windows the pack id is invisible; on macOS it
names the thing in /Applications and in the Dock. This key is what puts
"DodoSSH" back in front of a person while the bundle keeps the id.
LSMinimumSystemVersion Without it macOS will happily launch this on a release the runtime was
never built for, and the user gets a dyld crash rather than a sentence.
NSHumanReadableCopyright Shown in the About panel. Absent, the panel shows a blank line.
-->
<plist version="1.0">
<dict>
<!--
CFBundleName is what the menu bar shows and is capped at 15 characters by convention;
CFBundleDisplayName is what Finder and the Dock show. Both say DodoSSH, and the bundle
directory does not. See the note above.
-->
<key>CFBundleName</key>
<string>DodoSSH</string>
<key>CFBundleDisplayName</key>
<string>DodoSSH</string>
<!--
Reverse-DNS under the domain this project actually controls. It is the identity Gatekeeper,
the notary service and the keychain all key off, so it is as irreversible as the Windows pack
id: changing it makes an update a different application, and it orphans anything the previous
identifier stored — including the Secure Enclave key MacDeviceKeyStore holds, which is scoped
to this identifier and cannot be migrated because its whole point is that it never leaves the
enclave.
-->
<key>CFBundleIdentifier</key>
<string>dev.dodotech.dodossh</string>
<!--
The apphost the publish produced, named for the product by <AssemblyName> in the csproj rather
than for the project. Must match --mainExe or the bundle launches nothing.
-->
<key>CFBundleExecutable</key>
<string>DodoSSH</string>
<!--
Both version keys take the numeric core only — 1.2.3 and never 1.2.3-rc.1 — because Apple
defines them as one to three dot-separated integers and notarization rejects what it cannot
parse. The full version, prerelease suffix and all, is in Velopack's release index, and that
is the one the updater compares. These two are for Finder and for Gatekeeper.
They are the same value rather than the usual marketing/build split, because there is no build
counter here that a release does not already bump.
-->
<key>CFBundleShortVersionString</key>
<string>@VERSION@</string>
<key>CFBundleVersion</key>
<string>@VERSION@</string>
<!--
The file name inside Contents/Resources, which is where --icon puts it. With a custom plist
nothing rewrites this key, so a rename of the asset that forgets this line produces a bundle
showing the generic application icon and no error anywhere.
-->
<key>CFBundleIconFile</key>
<string>dodossh.icns</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<!--
12.0, and it is read off the binaries rather than off a support matrix. The apphost and
libcoreclr.dylib in a net10.0 osx-arm64 publish both carry LC_BUILD_VERSION with minos 12.0.0,
so 12.0 is the oldest release these bytes are built to load on.
Microsoft's *support* statement for .NET 10 is higher than this, and that difference is
deliberate rather than overlooked: this key decides whether macOS refuses to launch the app at
all, and refusing on a release where it would in fact have run is the worse of the two errors.
A user on an unsupported-but-working macOS gets the application; the support matrix governs
what gets fixed if it misbehaves there, which is a different question.
-->
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<!--
Without this the window is drawn at 1x and scaled up, which on a Retina display turns the
terminal — the one surface in this application that is nothing but small text — into a blur.
-->
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<!--
False, and stated rather than left out. An agent application has no Dock icon and no menu bar;
this one is an ordinary windowed application and the default is already false, but the key
being absent is indistinguishable from somebody having removed it.
-->
<key>LSUIElement</key>
<false/>
<!--
The dodossh:// scheme, which is how Spelunk's "Open in DodoSSH" reaches the application. macOS
does not pass the link as an argument: Launch Services sends an "open URL" event to the running
application, or to the one it has just started, and DodoSshApp.ListenForOpenedLinks takes it from
Avalonia's activation event. Registered when the bundle is first seen by Launch Services, which
for a Velopack install is on first launch from /Applications.
-->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>dev.dodotech.dodossh.link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>dodossh</string>
</array>
</dict>
</array>
<key>NSHumanReadableCopyright</key>
<string>© DodoTech. MIT licensed.</string>
</dict>
</plist>
+6 -1
View File
@@ -1,7 +1,12 @@
# Development dependencies only: PostgreSQL and Keycloak.
#
# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the
# schema is still churning. The production stack is deploy/docker-compose.yml.
# schema is still churning.
#
# There is no production compose file in this repository. This one said there was, naming a
# deploy/docker-compose.yml that has never existed in any commit. What ships instead is the
# container image the CI image job builds and pushes; a compose file to run it belongs to the
# self-hosting guide in M4, and until that exists the honest thing to say is that it does not.
#
# docker compose -f deploy/docker-compose.dev.yml up -d
# dotnet run --project src/DodoSSH.Api
+2 -1
View File
@@ -26,7 +26,8 @@
"post.logout.redirect.uris": "http://127.0.0.1/*"
},
"redirectUris": [
"http://127.0.0.1/callback"
"http://127.0.0.1/callback",
"dev.dodotech.dodossh:/callback"
],
"webOrigins": [],
"protocolMappers": [
-349
View File
@@ -1,349 +0,0 @@
# Adding hosts on the phone
The phone can read a keychain and connect through it. It cannot put anything in one. This document is the
plan for the change that fixes that, and it is written to be picked up cold — the decisions, the reasons, the
ordered work, and the traps that are already known.
> **Status: built.** All six steps. Each one compiled with the whole suite green before the next began,
> which is the rule the ordering below sets.
>
> | Step | State | Notes |
> | --- | --- | --- |
> | 1. `HostGroupSecret` grows | **Done** | Five fields, a version rule the codec did not have, a byte pin, and the "groups are flat" prose rewritten in all four places it appeared. |
> | 2. The `Tag` item kind | **Done** | Secret, codec, merge, cipher, repository, both registries, EF entity and the generated `AddTagItem` migration. |
> | 3. `HostSecret` grows, `Port` goes nullable | **Done** | `TagSet`, `TagIds`, `Port` as `int?`, `AsksForPassword`, both schema versions, and `HostInheritance` — the resolver. |
> | 4. The shared view model | **Done** | Every choke point below, plus the group editor the plan never assigned a step to and without which nothing could set a default at all. |
> | 5. The phone | **Done** | The `+`, the sheet, both editor cards, the back guard and EDIT on the connect bar. |
> | 6. Tests and false prose | **Done** | Plus a fourth "groups are flat" site in `README.md` this plan did not list, and a cross-vault resolution bug the scouting for it turned up. |
>
> **Tags shipped after the six steps, in a seventh pass.** They were storable and unreachable when step 6
> closed — a full item kind that no screen drew. Both heads now show a chip per tag on a host row and toggle
> them in the host editor, which also creates one inline, because wanting a tag and tagging a host are the
> same moment. Renaming and deleting live in a TAGS category on the keychain screen: a tag is an item so
> that renaming it is one write instead of twenty, and that rename needed somewhere to happen.
>
> That pass also made the desktop host editor scroll. A picker's height is a chip per tag in the keychain,
> so no fixed height holds it — which the layout suite caught the moment its seeder grew tags.
>
> **`HostFields.From` was answered by a refusal rather than by threading the resolver into the sync engine.**
> A relay host may not inherit its port; `HostSecret.TryValidate` refuses one that tries. A plaintext column
> derived from a *different* item goes stale when that item is edited and nothing re-pushes the hosts beneath
> it, so the relay would keep dialling the old port — which is worse than the restriction.
>
> **One decision was taken that this plan did not specify.** "Three states where there were two" is four, not
> three: a host can bind a key, bind a credential, be pinned to a typed password, or take its group's answer,
> and two nullable ids express three of those. Naming neither id now means *inherit*, so
> `HostSecret.AsksForPassword` was added to say "a typed password, even under a group that lends a key" out
> loud. Nothing stored changed meaning — no group could lend a binding before this build, so every existing
> host resolves exactly as it did.
## What was asked for
A floating `+` at the bottom right of the phone's HOSTS screen, opening a menu at the bottom offering **new
host** or **new group**.
A **host** carries: which vault it is filed into (personal or a team's), an alias, a hostname or IP address, a
group, tags, an SSH port, and a username with either a password or an SSH key.
A **group** carries: an alias, an optional parent group, and a default SSH port, username and
password-or-key — **defaults for the hosts inside it**.
## The shape of the problem
Three of those fields do not exist at any layer, and one of them is refused on the record.
| Asked for | What exists today |
| --- | --- |
| The `+` and the editors behind it | Nothing. `Theme/Phone.axaml` has no `.fab` class, and its comment says an unused style would be "a claim that the control exists somewhere". `HostsScreen.axaml`'s own v2 note says hosts are created on the desktop and sync down. *(This row named a `ConnectionsScreen.axaml` as a second site. No such file exists or ever has; and the `HostsScreen` statement is an XAML comment, so no phone screen ever rendered that sentence to a user.)* |
| Vault picker | **Built.** `VaultViewModel.TargetVaults` / `SelectedTargetVault` / `HasVaultChoice`, hidden at one vault. The desktop's keychain screen already draws it. |
| Alias, hostname, port, username, key-or-password, group | **Built**, in the shared `VaultViewModel` host editor — `EditorLabel`, `EditorHostname`, `EditorPort`, `EditorUsername`, `EditorAuthenticationChoices`, `EditorGroupChoices`, `SaveHostCommand`. The phone has never bound any of it. |
| Tags | **Nothing.** `SyncEntityType.Tag = 5` and `HostTag = 6` are reserved slots with nothing behind them. `HostSecret` has no tag field. |
| A group's parent | **Refused on the record.** `HostGroupSecret`'s own remark says groups are flat because two clients can each re-parent A under B and B under A offline, a scalar merge accepts both, and the result is a cycle no reader can draw and the server cannot see, because it is inside the payload. |
| A group's defaults | **Nothing.** `HostGroupSecret` has exactly one field, `Label`. |
So the phone half is mostly wiring. The domain half is three new capabilities, and one of them overturns a
written decision.
## The three decisions
**Tags are a real vault item, and a host names them.** A tag is a shared, reusable thing — the point of it is
to put the same tag on twenty machines and filter by it later — so it needs an identity of its own, not a
string repeated inside twenty payloads. `SyncEntityType.Tag = 5` becomes a live item kind. Membership is a
`TagIds` set on `HostSecret`, **not** the reserved `HostTag = 6` join.
The reason to skip the join is that the merge does not need it. The argument for `HostSecret.GroupId` living on
the host is that filing two hosts into one group must be two writes to two items; the same holds here. What a
join would have bought beyond that is two machines tagging *the same* host without one of them losing — and
`ThreeWayMerge.Map` already gives that. It resolves a keyed collection key by key, honouring presence and
absence, which is set semantics with removals. `HostOptions` is merged through it today via `ToNameMap()`, and
a tag set keyed by tag id is the same shape. So `HostTag = 6` stays reserved and unused, and the second run
through the item-kind checklist is not spent.
**Group defaults are inherited, not copied.** A host that leaves a field blank uses its group's value; a host
that fills one in overrides it. The editor shows the group's value as the field's placeholder, so the form
says what leaving it blank will get you. Copying the values into the host at create time was the alternative
and it was rejected: it makes a group a one-shot template rather than a live default, and changing a group
would then leave every host that had ever been created under it pinned to the old value.
**Groups get a parent, and the resolver breaks cycles.** `HostGroupSecret` gains `ParentId`. The editor refuses
a parent that is already a descendant, which stops a cycle being made here; the resolver walks with a visited
set and stops at a repeat, which contains a cycle that arrives from somewhere else. A cycle therefore degrades
to a group that reads as a root — flat headings, defaults unresolved past that point — instead of hanging the
connect path, and clearing the parent in the editor is the repair. **This is the load-bearing part of the
decision:** with inheritance the chain is walked at connect time, so an unguarded cycle is not an undrawable
sidebar, it is a shell that never opens.
`HostGroupSecret`'s "No parent. Groups are flat." remark and `HostGroupSecretTests`' class remark both become
false prose and must be rewritten to say what replaced the argument, not deleted. Same for the four rows in
`docs/design-import-gaps.md` recording the phone's missing `+`.
## What the payload becomes
```
HostSecret HostGroupSecret
Label Label
Hostname ParentId ← new, optional
Port int? — null inherits DefaultPort ← new
Username null inherits, "" none DefaultUsername ← new
Notes DefaultSshKeyId ← new
JumpHostIds DefaultCredentialId ← new
Options
SshKeyId null inherits TagSecret
CredentialId null inherits Label
AsksForPassword ← new, true only
GroupId null = ungrouped
TagIds ← new
RelayEnabled
```
`Port` becoming `int?` is the change with the widest blast radius, and it is unavoidable: `int` with a default
of 22 has no way to say "I have no port of my own". `HostSecret.DefaultPort = 22` stays, as the last fallback
after the chain runs out.
### Three states where there were two
`SshKeyId` and `CredentialId` both null currently *means* "ask for a password each time" — a decision, not an
absence. `AuthenticationChoice`'s own remark argues at length that the two must never be conflated.
Inheritance adds a state, so the picker needs an explicit **"Inherit from group"** entry beside **"Password
(ask each time)"**, and `Bound(...)` needs to distinguish them. `Username` has the same problem: null means
"no username" today and is refused at connect; it has to come to mean "inherit", with "no username" still
reachable and still refused.
**Built as four states, not three, because two nullable ids only carry three.** Key, credential, typed
password, inherit — and naming neither id was the third and is now the fourth. `HostSecret.AsksForPassword`
carries the difference: null is "not stated", which walks the chain and lands on a typed password if the
chain lends nothing, and `true` is "a typed password, even under a group that lends a key". Only `true` is
ever written, and a decoded `false` folds back to null, so a host that never touched the field encodes
exactly as it did before it existed. `Username` needed no field — an empty string is "no username" and null
is "inherit". `Port` needed none either: there is no "explicitly no port", only 22 at the end of the chain.
Mutual exclusion moves with it. `HostSecret.TryValidate` enforces "a key or a credential, never both" per
record; a host naming a credential under a group naming a key is two individually valid records that resolve to
two bindings. **Exclusion has to be enforced at resolution**, host-over-group, and the resolver must never
return both.
## The order to build it in
Each step compiles and its tests pass before the next one starts. Steps 1–3 are the domain and are
prerequisites for everything; step 4 is the only user-visible one.
### 1. `HostGroupSecret` grows
`src/DodoSSH.Client.Domain/HostGroupSecret.cs`, `HostGroupSecretCodec.cs`, `HostGroupSecretMerge.cs`.
- Add `ParentId`, `DefaultPort`, `DefaultUsername`, `DefaultSshKeyId`, `DefaultCredentialId`.
- `TryValidate`: reject `Guid.Empty` for each id, reject a `ParentId` equal to the group's own id where that is
knowable, apply the same 1–65535 range rule to `DefaultPort`, and refuse a group naming both a default key
and a default credential.
- **The codec needs the host codec's rule, which it does not have today.** `HostGroupSecretCodec` stamps
`CurrentSchemaVersion` unconditionally. Copy `HostSecretCodec.SchemaVersionFor` — a *maximum over the fields
actually present*, not a ladder — or upgrading one machine makes every group read-only on every other. A
group carrying none of the new fields must still encode at version 1, byte for byte.
- `HostGroupSecretMerge` gains the five fields, each a scalar.
- Add a group byte pin mirroring `AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne`. The group codec has
none, and the same "old vaults must re-encode identically" argument applies to it.
### 2. The `Tag` item kind
`SyncEntityType.Tag = 5`, `CryptoSpec.AadResourceType.Tag = 8`, `ChangeEntityType.Tag = 5` and the
`PublicAPI.Unshipped.txt` line **already exist** — no contract change, no crypto-spec change, no `docs/crypto.md`
change. `LocalCacheProtector`'s AAD switch already maps `Tag`.
New: `TagSecret.cs`, `TagSecretCodec.cs`, `TagSecretMerge.cs` in `DodoSSH.Client.Domain`; `TagCipher.cs`,
`TagRepository.cs` in `DodoSSH.Client.Sync`. Edited: `Client.Sync/ItemKinds.cs` (registry entry + `TagKind`),
`VaultSession.cs` (a `Tags` repository beside `HostGroups`), `Domain/Hosts.cs` (`VaultTag`),
`Infrastructure/DodoDbContext.cs`, `Infrastructure/Configurations/HostAndSyncConfigurations.cs`,
`Api/Features/Sync/ItemKinds.cs` (registry entry + server `TagKind`).
**A server migration is required** — a tag is its own table. Generate it, do not hand-write it:
`dotnet ef migrations add AddTagItem --project src/DodoSSH.Infrastructure`, committing all three resulting file
changes. **No client migration**: `ClientCacheContext` has one `item` table keyed by `(VaultId, EntityType,
EntityId)`, so a new kind is new rows.
Four traps, all of which pass a naive test suite:
- **`TagCipher` must say `AadResourceType.Tag` as a constant, never cast from `SyncEntityType.Tag`.** Tag is 5
on the wire and 8 in the crypto enum. A cast seals tags under `Credential`, which encrypts and decrypts
perfectly on the machine that wrote it and is a spec violation nothing notices until another implementation
refuses the item — and the AAD is frozen into stored ciphertext. `AadResourceTypeTests` is the test that
catches this, and it only catches it once `PinnedPairs` and `SealSample` have a Tag arm.
- **The `ItemKinds.Registry` entry is what `SyncEngine` pulls with.** `SyncedTypes` is projected from it. Miss
it and tags encrypt, merge and list perfectly on the machine that made them and are never once requested from
the server.
- **`TagKind.Fields` returns `null`, not an empty `SyncPlaintextFields`.** An empty record still serialises
`relayEnabled: false`, which invites a reader to believe the type has a relay setting that is off.
- **`ValidateFields` must refuse everything, with reasons.** There is no default; an unimplemented one accepts
whatever arrives. `RelatedId` matters more here than it did for a group — it is the field a future `HostTag`
would reach for, and `SyncPlaintextFields` is frozen so it cannot be removed. Follow `SnippetKind` and
`ObjectStoreKind`, which are more complete than `HostGroupKind`.
### 3. `HostSecret` grows, and `Port` goes nullable
`HostSecret.cs`, `HostSecretCodec.cs`, `HostSecretMerge.cs`, plus a new `TagSet` value type and a new resolver.
- `TagSet` is a structural-equality wrapper in the shape of `JumpChain` — a plain `IReadOnlyList<Guid>` on a
record gets reference equality from the compiler-generated `Equals`, which would make every host read as
changed on every sync pass and two identical edits register as a conflict. Unlike `JumpChain` it is a *set*:
order carries no meaning, so it sorts and dedupes, and it exposes a `ToIdMap()` for the merge.
- `HostSecretMerge` merges `TagIds` through `ThreeWayMerge.Map`, per tag, so two people each adding a different
tag to one host both keep theirs. It is not a scalar; a whole-value merge here would silently drop one side.
- `Port` becomes `int?`. `TryValidate`'s `is < 1 or > 65535` is already false for null, which is the wanted
behaviour but is silent — say so in a comment, and fix the message, which interpolates `{Port}` and renders
empty for null.
- **`SchemaVersionFor` needs branches for both new fields, and this is the one that loses data if forgotten.**
A host with `Port = null` written at version 1 omits the property (`WhenWritingNull`); an older client
deserialises `int Port` as 0, `TryValidate` refuses it, and the item reads as **unreadable rather than
read-only** — gone, not locked. Add `PortInheritSchemaVersion = 5` and `TagIdsSchemaVersion = 5` (or 5 and 6)
and make both bump the maximum.
- **`AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne` pins the exact bytes** of a minimal host,
including `"port":22`. A host that now inherits its port drops that key. Keep the pinned literal for a host
with an explicit port, and add a companion for the inheriting one — do not edit the old literal to match
whatever the code produces.
- `TagIds` must serialise **last** in `HostPayloadDocument`, and empty must serialise as absent, or every host
in every vault looks changed on the first sync after the upgrade.
New file: the resolver. Given a host and the group list, walk `GroupId` then `ParentId` with a visited set,
returning resolved port, username and binding, plus which group each came from — the editor needs the second
half for its placeholders. It must never return both a key and a credential.
### 4. The shared view model, and the two heads
`VaultViewModel` is where nearly all of it lands, and both heads get it at once because both bind the same
properties.
The choke points, from the connect-path trace:
- **`TryBuildAuthentication(HostSecret, …)` and `TryBuildConnectionRequest(HostSecret, …)`** both take a bare
`HostSecret` — this signature is where group context is currently lost. Both must take the resolved value.
This is the single highest-leverage change: it is the only auth resolution in the product, and both heads
and both transports go through it.
- The credential-username fallback becomes three levels: credential → host → group chain.
- `Complete(...)` must refuse on an empty username only *after* the chain has been consulted.
- `HostRowViewModel.Address`, `Dialled`, `TransfersViewModel`'s connected-to label and
`TerminalWorkspace.Describe` must show the **dialled** address. `MainWindowViewModel.Rank` searches
`Address`, so an unresolved one makes hosts unfindable by their real port.
- **`HostFields.From` in `HostCipher` must emit the resolved port.** This is the one place where an unresolved
read is a wrong wire rather than a wrong label: it fills the relay's plaintext columns, which is what the
*server* dials. A relay host with a null port also fails server validation outright.
- Host-key pinning keys on host and port. `ForgetHostKeyAsync` and the "pin in use" endpoint set read
`row.Host.Port` directly — a host inheriting 2222 pins under 2222 while both look under 22.
- `HostRowViewModel.Authentication`, `SelectedHostAsksForAPassword` (in **both** `VaultViewModel` and
`TransfersViewModel`) and `SelectedHostAuthenticationNote` all switch on the host's own two ids and must
switch on the resolved binding, with new wording for a binding that came from a group.
- **`HostsBoundTo`** counts hosts naming a key explicitly. A key deleted while only a *group* names it warns
nobody and then refuses every host under that group at connect time. It must walk groups too.
- The editor: load must distinguish "unset" from "explicitly this", `BuildHost` must preserve that in both
directions, and each field needs the resolved value as its placeholder.
- `ImportViewModel.IsAlreadyPresent` compares port and username directly, so a stored host inheriting 22 will
never match an imported host at 22 and the import screen re-offers duplicates. Decide whether an imported
block with no `Port` should inherit rather than pin 22 — `SshConfigResolver` returns `DefaultPort` today.
### 5. The phone
`Views/HostsScreen.axaml` and `Theme/Phone.axaml`. No csproj edit — Avalonia globs `**/*.axaml`. Compiled
bindings are on, so a binding to a property that does not exist is a build error; `TreatWarningsAsErrors` is on
repo-wide, so any XAML warning fails CI.
- A `Button.fab` style, and the comment that currently refuses one rewritten to say what it now opens.
- The `+` needs a `Panel` wrapping the screen's root `Grid` so it can sit bottom-right without moving. Placing
it inside the list row would put it above the connect bar, which appears and disappears with the selection.
- The bottom sheet follows `HostKeySheet.axaml`, the head's one existing sheet: a scrim
(`Border Background="#9E0E1220"`, a literal because the palette holds no alpha surface), a bottom-aligned
`Border` with `CornerRadius="22,22,0,0"`, a decorative grab handle, and **no tap-to-dismiss** unless it is
wired by hand, because nothing on this head implements it.
- **Scrim reach is a real choice.** Declared inside `HostsScreen`, a scrim darkens the screen area only and the
64px nav bar stays lit and tappable. To cover the display the sheet has to sit in `PhoneShell.axaml` beside
`HostKeySheet`, which is exactly why that control lives there.
- **The back gesture needs a new guard**, above the `switch` in `PhoneShell.axaml.cs`, in the shape of the
host-key guard: back should close the sheet or the editor rather than leave the screen. The comment on that
method states an invariant about the switch cases staying in step with the surface tests — so add a guard,
do not extend the switch. *(This named `IsVaultsSurface` and `IsConnectionsSurface`. Neither exists
anywhere in the repo; the real properties are `IsMoreSurface` and `IsTerminalSurface`, and only the first
is what the switch mirrors.)*
- The editors follow `SnippetsScreen.axaml`: a `Border Classes="card"` in the same grid row as the list,
toggled on `IsEditing`, with the list toggled on `!IsEditing`. There is no dialog, no modal and no
editor-screen anywhere on this head, and the reason is written down — a form stacked over the list hides what
it is about.
- Editing an existing host comes nearly free: the shared `IsEditing` panel serves both, so the connect bar gets
an EDIT button beside CONNECT. Not asked for, but a `+` that adds hosts with no way to correct one is a
strange thing to ship.
### 6. Tests
House style: `Subject_Predicate`, articles included, the predicate smuggling in the reason
(`_RefusesRatherThanFallingBackToThePassword`). No `Async` suffix on test methods; always on private helpers.
Every class carries a remark naming the class of failure it exists to catch. xunit.v3 + Shouldly, with the
because-string used as prose.
- Three shared builders need the new fields: `Domain.Tests/HostFactory.cs`, `Sync.Tests/SyncHarness.cs`,
`Sync.Tests/HostCipherTests.cs`. There is no shared group factory and with five new fields there should be.
- Three guard tests fail by design and that is what they are for:
`HostSecretMergeTests.EveryScalarField_IsRoutedThroughAMerge` (note `GroupId` is missing from its `with`
block today — an existing gap worth closing while there) *(closed: `GroupId` is in that block now)*,
`ValueSemanticsTests.TryValidate_RejectsWhatCannotBeStored`, and the two "groups are flat" remarks.
- `ItemKindsTests.ThePullFilterNamesEveryTypeThisBuildSynchronises` is an ordered list — add Tag in registry
order. `AadResourceTypeTests` needs `PinnedPairs`, a `SealSample` arm and a `NewTag()` builder.
- `SyncEndpointTests.AHostGroupCarryingAParent_IsRejected` asserts the server refuses a plaintext `ParentId`
with a reason containing "flat". Putting the parent **inside the payload** does not break it mechanically,
but its stated reason becomes wrong. It should survive with a rewritten comment — the parent lives in the
payload, the plaintext column stays refused, and ADR 0004 is why. Decide that deliberately rather than
letting it drift. *(Decided that way, and the name says it now:
`SyncEndpointTests.AHostGroupCarryingAPlaintextParent_IsRejected`. The column is still refused and the
reason is the trust model rather than flatness.)*
- `Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch` picks the first type with no kind registered, very
likely Tag today. It self-heals to `HostTag` via `Assert.SkipWhen`.
- `App.Layout.Tests` measures pixels and bends rather than breaks. Extend the seeders — nesting and a tag row
change what the widest realistic content is — rather than the assertions. `ScreenLayoutTests.cs:1310` pins
`ChangedFields = "Hostname, Port, Username, Options, Group"` as a literal; add `Tags` to keep it measuring
the worst case.
- `ShellFlowTests.ReadyToConnectAsync` gives its host both a username and a port, so the inheritance tests need
a host with neither, or the group is never the source.
## Found on the way, and out of scope
`LocalCacheProtector`'s AAD switch is missing `ConnectionLogEntry`, `ActivityLogEntry` and `ObjectStore`, so
`ConflictStore.Record` throws `ArgumentOutOfRangeException` on a conflict for any of those three. Pre-existing,
unrelated to any of this, and `Tag` is already in that switch. Worth a separate fix.
> **Fixed 2026-08-04**, having outlived the phases that shipped the logs and the buckets — which is exactly
> the drift a note like this is meant to prevent, so it is worth saying what let it last. Of the three callers
> of `AadResourceTypes.For`, two reach it only when an item carries plaintext fields and none of these three
> does; the third, `ConflictStore.RecordAsync`, calls it unconditionally but is reached only by a real merge
> conflict, which every existing test raised against a `Host`. The arms are in, and two tests now hold them
> there: `CacheStoreTests.AConflict_CanBeRecordedForEveryKindOfItem` records one per kind, and
> `AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping` fails on the *next* item type added
> without one, by name rather than by a list kept by hand.
## Prose that becomes false
Not a tidy-up — these are the places the codebase currently tells a user or a maintainer that this feature is
deliberately absent, and each states a reason that will no longer hold.
- `Theme/Phone.axaml` — the "No floating action button" block.
- `Views/HostsScreen.axaml` — the v2 and v3 notes on the missing `+`.
- ~~`Views/ConnectionsScreen.axaml`~~ — **this file does not exist and never did.** The real inventory is
the two above plus `README.md`, `docs/android-port.md` and `docs/design-import-gaps.md`. Neither phone
site was "rendered on screen": both are XAML comments.
- `HostGroupSecret.cs` and `HostGroupSecretTests.cs` — "No parent. Groups are flat."
- `docs/design-import-gaps.md` — the **Add host** row, the two floating-button rows, the "Tag / HostTag still
reserved and unused" paragraph, and the tag-chips and groups-on-a-team's-hosts rows in the Hosts table.
+19
View File
@@ -99,3 +99,22 @@ real ones.
- **Client-supplied target address.** One line of convenience, and the relay becomes an open
proxy into the operator's network.
- **Allow-listing by hostname.** Defeated by DNS rebinding.
## Implementation notes
Built in `DodoSSH.Api/Features/Relay` and, on the client, `RelayBridge` and `ServerRelay`; see
`docs/reaching-a-host-you-cannot-dial.md`, "What step 3 built". Three places differ from the text above:
- **Backpressure is an awaited copy loop, not `System.IO.Pipelines`.** Each direction reads one buffer and
writes it before reading again. The property is the same — a slow peer stops the read, and TCP throttles
the origin — and the bound is tighter: two 32 KiB buffers per session.
- **There is no `?ticket=` fallback.** Both clients can set a subprotocol, and the fallback's cost — a bearer
credential in the URL — was the reason it needed scrubbing at all. It can be added if a constrained
client ever needs it; the scrubbing requirement stands.
- **The ticket carries no device id.** The server does not see a device on an ordinary request, so there was
nothing to put there. `PortForwardId` on the request is refused: a forward's port is ciphertext, and
honouring one would mean taking the port from the client.
The loopback bridge serves the relay only. SSH.NET's `ForwardedPortDynamic` already is the jump-host path's
listener, so "one mechanism, two features" is true one level down: both features reach their target through
a loopback SOCKS5 proxy named on the connection request.
+89 -4
View File
@@ -1,4 +1,4 @@
# ADR 0007 — What protects the device key on Windows
# ADR 0007 — What protects the device key on the desktop
**Status:** accepted, 2026-07-30
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
@@ -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,19 @@ 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
platform's rather than the process's — which is the entire point of the 2026-07-30 amendment above, and
the thing a self-drawn prompt over a protected file would fail to be. The mechanical differences are
incidental: P-256 with ECIES because the enclave holds no other kind of key, and no prompt when sealing
because the public half needs no consent. See docs/platform-flags.md for the three ordinary Macs where the
probe answers no, one of which is every unsigned development build.
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
@@ -85,13 +85,38 @@ packs and stops. Phase two, a separate invocation, asks for the token and upload
what reaches users has been installed and started by a human first, and so that the credential is in memory
only for the minutes that need it.
There is a second, smaller reason the release could not be a CI job here anyway: `vpk` stamps and embeds
the `Setup.exe` and `Update.exe` stubs with Windows tooling, and every job in `ci.yml` is
`runs-on: [linux]`. Both reasons are recorded because somebody will fix one and believe they are done.
A second, smaller reason was recorded here — that `vpk` stamps and embeds the `Setup.exe` and
`Update.exe` stubs with Windows tooling, and every job in `ci.yml` is `runs-on: [linux]` — on the
reasoning that somebody will fix one reason and believe they are done.
What CI does gain is the same thing the `android` job already does — it proves the artefact still builds.
A `win-x64` publish runs on main and on tags, so a restore graph that resolves for `net10.0` and not for
`net10.0/win-x64` fails there rather than under a person midway through a release.
◆ **That second reason was not true, and the correction is worth more than the claim was.** `vpk`
cross-compiles when it is told to: the bracketed OS directive, `vpk [win] pack`, builds the setup stub,
the portable bundle and the `.nupkg` on Linux, and reports `Directive enabled for cross-compiling from
Linux (current os) to Windows` while doing it. Only signing needs Windows tooling, and rule 8 above is
why nothing is signed yet. Plain `vpk pack` on Linux targets Linux and rejects a Windows RID, which is
the shape of the mistake: the tool does not fail because it cannot, it fails because it was not asked.
The correction does not move this rule at all, and that is the point of separating the two. The reason
that stands was never mechanical, so a Linux runner that *can* pack changes nothing: what may not be on
a runner is the token, not the build.
What CI gains is the same thing the `android` job already does — it proves the artefact still builds and
still packages. On main and on tags the build job publishes `win-x64` and then packs it, so a restore
graph that resolves for `net10.0` and not for `net10.0/win-x64` fails there rather than under a person
midway through a release. Packaging is the more valuable half and adds a few seconds to a publish that
has already happened — measured at two to seven, against the minutes the publish itself takes: `vpk` verifies
that the main executable actually calls `VelopackApp.Build().Run()`, so the change that quietly produces
an application which never updates itself — compiling, tested, and invisible until a release goes
unreceived — fails in CI. The packages are written to `RUNNER_TEMP` and discarded, and are deliberately
not offered as workflow artefacts: an installer nobody has run should not sit somewhere that invites
passing it on.
CI does not call `scripts/release-windows.ps1` to do any of this, and should not. That script is the
person's procedure — it refuses a dirty tree, requires a tagged `HEAD`, fetches the previous release for
deltas, and prompts for the token — so running it on a runner would mean either weakening it with CI
switches or having CI satisfy conditions that only make sense at a desk. The workflow writes the same
`vpk` arguments out in shell, and the constants they share (pack id, title, authors, channel, icon) are
noted in both places as a contract with `VelopackUpdateChannel` and with every installed client.
### 4. The update check points at the project's forge, and the address is a constant
@@ -203,6 +228,194 @@ changes.
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
layer down, declined there for reasons that do not stop applying because the vendor changed.
**This rule is Windows-only, and macOS gets the opposite one.** See decision 10: there is no "unsigned for
now" available on that platform at any price, because Gatekeeper refuses rather than warns.
### 10. macOS is a second desktop platform on the same machinery, signed from the start
The macOS head is the same application, the same Velopack, and the same two-phase person-run release. Four
things differ, and each is forced rather than chosen.
**Signing is a precondition, not an improvement.** Decision 8's whole argument — one dialog per user per
lifetime, buy a certificate when a stranger is invited to install — has no macOS equivalent. An
un-notarized download is refused outright, so the Developer ID certificate and the notarization round trip
are the price of the package existing. `scripts/release-macos.sh` therefore refuses to run without the
signing identities, where the Windows script refuses nothing.
**The channels are `osx` and `osx-nightly`, and they are separate for decision 9's reason.** Four channels
now publish to one repository, and the only thing keeping a Mac from being offered a Windows package is
that it never reads that index. The macOS nightly channel is named and has no publisher: CI builds and
bundles the macOS head to prove it still builds, and uploads nothing, exactly as it does for the Windows
release channel.
**The pack id is shared with Windows, and on macOS it is visible.** vpk names the bundle after the pack id,
so `/Applications` holds `DodoSSH.Desktop.app`. Decision 2's reasoning applies with more force here rather
than less: a pack id of `DodoSSH` would put Velopack's install root on `~/Library/Application
Support/DodoSSH`, which is `ClientPaths.DataDirectory`, and an uninstall would take the user's un-synced
outbox with it. `CFBundleDisplayName` puts the product name back in front of a person; the directory keeps
the id.
**arm64 only, because the check is the scarce thing.** Velopack keys a channel to one architecture, and an
Intel package would be the only artefact in this repository reaching users without somebody having walked
Phase 18 against it. The engineering for a second channel is small and is described in the release script;
what is missing is an Intel Mac to verify on, and shipping blind is the thing this project's manual-check
discipline exists to refuse.
**And one thing that does not differ, which is worth saying because it is the expensive half.** The
capability to publish still lives on a person's machine and never in CI. Notarization does not change that:
Apple's ticket says this build came from this developer account, and says nothing about whether the build
should have been made. Velopack clients still apply what their feed serves. Rule 3 is untouched.
### 9. There is a second desktop channel, published by CI, and it is a second application
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
none, which left the two heads with different answers to the same question — *how does somebody try what
is on main?* — for no reason other than the order the work happened in. This is the desktop's answer, and
it is the phone's arrangement with one difference that changes how much of it has to be built.
**Android gets the separation from the platform. Windows does not.** Two Android channels cannot replace
one another because the installer refuses a package signed by a different key; a mistake there is loud.
Velopack applies what its feed serves and verifies no signature, so on this head the separation is entirely
construction:
| | release | nightly |
| --- | --- | --- |
| pack id | `DodoSSH.Desktop` | `DodoSSH.Desktop.Nightly` |
| Velopack channel | `win` | `win-nightly` |
| feed | newest non-prerelease release | the rolling `nightly-desktop` prerelease |
| profile | `%LOCALAPPDATA%\DodoSSH` | `%LOCALAPPDATA%\DodoSSH.Nightly` |
| cut by | a person, from a `v*` tag | CI, on every push to main |
Four separations rather than one, because each closes a different door. The pack id decides the install
directory and what an installed client matches an update against, so it is what makes a nightly install
*beside* rather than *over*. The channel names the index file on the feed, so neither build ever reads the
other's — and the release channel additionally refuses prereleases, which is belt and braces in the one
direction that matters: an unsigned CI build must never reach a machine somebody is trusting with their
credentials. The profile is the one that is easy to skip and would hurt most: the cache schema is migrated
on every launch, before unlock, so a shared profile means a nightly quietly upgrading a database the
release build then opens.
**The prerelease flag is also how the two heads stay out of each other's way.** The phone's release channel
reads `releases/latest`, which skips prereleases. A desktop nightly published as a stable release would
become the newest release in this repository, and every phone on the release channel would start failing
its check against a release carrying no Android manifest. The nightly is published with `--pre` for its own
sake and for that one.
**What this costs, stated where somebody will read it before installing:** whoever can write a release on
this repository can put a build on every nightly desktop, because the client applies what the feed serves
without verifying a signature. That set includes CI and therefore everyone who can change a workflow file.
It is the same trade [ADR 0014](0014-android-updates.md) accepted for the phone's nightly, and it is
acceptable for the same reason and only that reason: this is not the channel anybody's real credentials are
on. Rule 3 is untouched — the release channel still has no job, no token and no runner.
**The version gets a floor, on this channel only.** MinVer answers `0.0.0-alpha.0.N` until the first `v*`
tag and `vpk` refuses to pack anything below `0.0.1`, so the nightly job lifts the patch digit and keeps
the prerelease height. It is applied through `MinVerVersionOverride`, which moves the assembly version too
— packing a number the assemblies disagree with would put one version in the installer and another on the
preferences screen, which is the screen somebody reads when asked which nightly they are running. Ordering
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
@@ -256,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
+21
View File
@@ -84,6 +84,18 @@ losing the local cache, the outbox and the device key.
turned this application on in the unknown-sources screen. Two deliberate answers, neither to a screen
DodoSSH controls.
◆ "Asks Android to ask" is one step longer than it sounds, and reading it as one step is why this
installed nothing at all from the day it shipped until 2026-08-05. An application holding only
`REQUEST_INSTALL_PACKAGES` gets no verdict back from committing a session: what the platform returns
first is `STATUS_PENDING_USER_ACTION`, carrying the confirmation activity in `Intent.EXTRA_INTENT` for
the application to start. **Android does not draw the dialogue on its own.** The commit succeeded, the
session staged, and nobody was ever asked anything. The receiver that starts it is
`InstallSessionReceiver`; the intent naming it has to be explicit, since a mutable pending intent
wrapping an implicit one is refused outright from API 34. Check 17.5 asks for exactly the right thing
— "Android's own installer appears naming the package" — so nothing needed rewording; it wants an
older build installed and a newer one published to run at all, and on the evidence it was never run
against a real pair.
7. **Applying does not end the process, and the shell had to learn that.** On Windows, applying replaces
the files and restarts, so the shell disposes the vault first — that is what zeroes the identity keys,
the vault keys and the cache key. On Android the install is a *request* and the answer may be no, so
@@ -104,6 +116,15 @@ is the entire reason there are two.
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
caches are encrypted under keys held by two package identities the platform keeps apart.
◆ **The desktop has since taken this arrangement, and it had to build what Android is given.**
[ADR 0013](0013-desktop-distribution-and-updates.md) decision 9 is this ADR applied to Windows: a nightly
CI publishes from main, installing beside the release build rather than over it. The difference worth
carrying back here is that every separation this ADR gets from the platform — different package identity,
signature-checked updates, a per-app data directory — is on Windows a thing somebody had to choose and can
therefore undo. The paragraph above about the attack surface applies there word for word, with one line
removed: on the desktop the packages are not signed at all, so the feed is the only thing standing between
a nightly install and an arbitrary build.
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
that an installed copy can now learn a newer one exists.
+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.
+103 -7
View File
@@ -30,7 +30,10 @@ verified is that it compiles, links, packages, and carries the right natives.
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
with the foreground service now counting transfers as well as shells.
with the foreground service now counting transfers as well as shells — and, since, an idle-but-connected
Files session as well, which a transfer count alone was blind to. *Corrected the same round:* the service's
other half — a shell's own opening — had never been wired to anything at all, so a shell survived only for
as long as the app stayed foreground; see [Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service).
**What was actually checked**, so the rest can be read with the right amount of trust:
@@ -235,6 +238,43 @@ The parts that are definitely different are the on-screen keyboard, and the fact
needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an
accessory key row for this. That is UI work, not porting.
> **⚠️ Corrected by the build. The data plane assumed a renderer that attaches once and lives forever, and
> that assumption is WebView2's truth, not Android's.** Desktop's WebView2 process starts with the window and
> dies with it; `TerminalDataPlane` was written to that reality — one socket, attached once,
> `Interlocked.Exchange`-guarded against a second attach ever happening at all. On a phone the WebView's own
> renderer process is a separate thing from the app process the foreground service above is keeping alive,
> and Android kills *that* independently — under memory pressure, or simply for being backgrounded — with no
> foreground service able to save it. The page then reloads with a fresh socket, and three things broke on
> that reload before this was found: the second attach was refused outright (`409 Conflict`), because a
> second valid upgrade could only mean a bug or a hostile second process, never our own page coming back; a
> send into the dead first socket threw, and that exception unwound `TerminalSessionPump`'s flush loop,
> freezing the still-live shell behind it — `LiveSessionCount` kept counting a session nothing would ever
> drain again; and every byte sent while no page was attached had already spent flow-control credit that no
> acknowledgement could ever return, so a session outliving 256 KiB of output into a dead page stalled for
> good regardless of the other two. Waiting for the old socket to notice it was dead and close on its own
> was never going to be enough either — a killed renderer sends no TCP FIN, so the old receive loop could sit
> unaware for the whole 30-second keepalive.
>
> Fixed as a takeover rather than a guard: a second valid upgrade — origin, token and subprotocol all
> checked exactly as before — now displaces whatever socket was attached instead of being refused, since
> only this app's own page ever knows the token, so a second valid attach *is* that page, back again.
> `TerminalDataPlane.SendAsync` no longer lets a dead-socket send escape as a fault; it reads as "nobody
> listening," same as no socket being attached at all. `TerminalWorkspace` resets each live session's credit
> window on every attach and resends its `SessionOpened` frame, flagged as a replay, so the fresh page
> rebuilds the pane and the pump stops waiting on an acknowledgement that was never coming. And
> `terminal.js`'s socket now retries itself, forever, with backoff, instead of reporting the connection
> failed and stopping — the page dies with the app anyway, so there is no case where retrying is the wrong
> call. What is **not** recovered, and says so rather than pretending otherwise: scrollback across a page
> reload. It lived in the page's own DOM, and a reloaded page is a new DOM. The replay banner — *"the view
> 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
@@ -280,13 +320,50 @@ What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, th
### Sessions survive backgrounding, via a foreground service
A persistent notification for as long as a shell or a transfer is live.
A persistent notification for as long as a shell, a transfer, or an idle-but-connected Files session is
live.
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
notification is to look at.
**Three corrections found after the first cut shipped, all in the wiring rather than the design:**
- **A shell opening never started the service.** `SessionKeepAlive` heard `TerminalWorkspace.SessionEnded`
and refreshed on that, but nothing announced the opposite event — so a user who opened a shell and
backgrounded the app immediately had no foreground service at all, and Android was free to kill the
process holding it. `MainWindowViewModel.TerminalSessionOpened` is now wired the same way in
`App.axaml.cs`'s `ComposeKeepAlive`.
- **A connected-but-idle Files session counted as nothing.** A host open on the Files screen with no
transfer moving is a live SFTP connection a dying process would sever, and the old two-argument
`Reconcile(liveSessions, activeTransfers)` had no way to hear about it. `TransfersViewModel.HasLiveFileSession`
— `IsConnected` with a real `ConnectedCipher`, which a bucket never has — is the third fact `Reconcile` now
takes.
- **Refreshing the notification restarted the service, which throws when backgrounded.** `Reconcile` called
`StartForegroundService` on every refresh, including the common case of a service that was already
running. On API 31+ that throws `ForegroundServiceStartNotAllowedException` the instant the app is
backgrounded — a transfer finishing in the pocket, one of two shells dying — which crashed the process and
took every session with it. `SessionForegroundService` now tracks whether it is already running and, when
it is, posts the updated notification through `NotificationManager.Notify` instead of asking Android to
start anything.
**The notification permission is requested, not just declared.** API 33+ requires `POST_NOTIFICATIONS` at
runtime or the receipt is silently invisible — the service still runs, but nothing on screen says so.
`SessionForegroundService.Reconcile` asks for it the first time in this process there is actually something
to show, at most once, with no result read back: a refusal costs the notification and nothing else, which is
what the manifest's own comment on the permission says.
**And a fourth correction, found by the notification refusing to come down.** "1 shell connected" outlived
the shell, both ways a shell can close. A shell exiting on its own announced `SessionEnded` from inside its
run's own finally block — where the run task is by definition not yet complete, so the
`LiveSessionCount` the keep-alive reads from that event still counted the session that had just ended, and
nothing fired afterwards to correct it. A tab closed by hand announced nothing at all, by a recorded
decision that assumed every subscriber was the closer. Both reversed in `TerminalWorkspace`: the end is now
announced from a continuation after the run has actually completed, and `CloseSessionAsync` announces too,
after its own drain — the event's remark carries the reversal, and `SessionEnded`'s subscribers were all
already "reconcile to reality" handlers for which a second announcement is harmless.
### Phone first
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
@@ -523,7 +600,12 @@ go at 360dp:
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
dishonesty the unlock screen's count exists to prevent.
dishonesty the unlock screen's count exists to prevent. *Corrected since:* the opened half of a shell's
lifecycle was never wired in, so the service could never come up for a shell at all; an idle-but-connected
Files session now counts as a third live fact rather than nothing; a refresh while backgrounded updates
the notification in place instead of restarting the service, which the API throws on; and
`POST_NOTIFICATIONS` is now actually requested rather than merely declared. See
[Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service) for all four.
7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design,
plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
choosing a passphrase).
@@ -549,7 +631,8 @@ go at 360dp:
for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together,
because it is shared. What v2 asked for and did not get is in
[`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is
named as absent rather than drawn, because nothing in the SSH layer forwards anything.
named rather than drawn as a screen: it has since been built, and this head runs a host's forwards with
every terminal and lists them in a sheet off the terminal's bar. It cannot edit them yet.
**The bar has since gone from four entries to three.** The keychain joined the hub, which is now called
SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and
@@ -568,9 +651,16 @@ go at 360dp:
believing they typed an `l`.
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
and the bottom bar while a terminal is showing — one binding on `IsShowingPages` each — and the screen
draws a 35-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three
connections there are. That sheet is the head's first control that could be drawn over the renderer, so
and the bottom bar while a shell is showing, and the screen draws a 35-pixel bar in their place: back,
the session pills, and a `+` raising a sheet with the three connections there are.
*A shell rather than the surface, and the two parted company once that surface gained a connect page.*
With nothing running, Connections is a box, a CONNECT button and the machines connected to before — a
page in everything but which enum it is in — so `RefreshChrome` keeps the bar (and, wide, the rail) under
it and the Connections entry lights for the first time. It is the one screen reachable by closing your
last tab, and collapsing the nav there left the system back gesture as the only route to Hosts or
Settings. The header is not part of that: the surface draws its own bar, and a vault header above it is
the second row of chrome this head exists to avoid. That sheet is the head's first control that could be drawn over the renderer, so
it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
WebView actually composites above Avalonia content is still the unverified question recorded below, and
collapsing is correct under either answer.
@@ -666,6 +756,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.
+5 -1
View File
@@ -299,7 +299,8 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore.
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore,
`17` SpelunkKey.
> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
> `HostCredential` as syncable since the contract was frozen, but this table had no value for
@@ -318,6 +319,9 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
> `AadResourceTypeTests` pins each pairing by name for exactly that reason.
> **Added 2026-09-29:** `17` SpelunkKey, a Spelunk API key and the address it may be sent to. It is
> `14` on the wire, three behind, as `16` ObjectStore is `13`.
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
### 4.4 What this prevents
+288 -25
View File
@@ -39,8 +39,8 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
>
> | v2 element | What ships instead |
> | --- | --- |
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps VAULTS in its rail. |
> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Built, as a sheet off the terminal rather than a screen, and running on the phone as on the desktop.** A host's forwards start with every terminal opened to it on either head — `MainWindowViewModel.StartForwards` no longer skips Android. The terminal's bar draws a `⇄` for a tab that has forwards, with the listening count in green and the glyph in the danger colour when one failed, and it raises a sheet (`IsForwardsSheetOpen`) listing `SelectedTab.Forwards` with notation, route, state word, failure reason and a tap-to-toggle per row — the desktop sidebar's FORWARDS section over the same rows. Not a screen, for the desktop's reason: a forward belongs to a host and runs with a session. **Two gaps remain.** The phone's host form has no PORT FORWARDS section, so forwards are edited on the desktop and arrive with the host; the MORE screen's paragraph, now tagged IN THE TERMINAL, says so. And byte counts are built on neither head: SSH.NET keeps none. The keep-alive notification counts listening forwards beside the shells they run over, and is never started for a forward alone, since a forward cannot outlive its shell. |
> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwarded anything at the time (forwards now have the `⇄` in the bar above; see the row before this one), so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. |
@@ -69,7 +69,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> labelled sidebar and the chrome went from 72 tall to 86, so `880x560` became `1016x574` — leaving every
> screen the same `826x464` it was designed against. Four of the tables stop fitting at 690 wide, so
> widening the sidebar without widening the window would have broken them where the layout suite was not
> looking.
> looking. **v5b grows it again**, to `1081x583` — see that section, below.
>
> **Buckets became a destination** rather than a toggle inside the files screen, matching the phone: the
> `HOST` / `BUCKET` pair is gone and `ShellScreen.Buckets` draws the same `TransfersScreen` with the other
@@ -78,7 +78,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
>
> | v2 element | What ships instead |
> | --- | --- |
> | The **FORWARDING** screen, the `⇄ N forwards` chip on the session strip, and the host detail's FORWARDING card | Nothing, as on the phone and for the same reason. Three surfaces for a feature the SSH layer does not have. |
> | The **FORWARDING** screen, the `⇄ N forwards` chip on the session strip, and the host detail's FORWARDING card | **Built, as three smaller things.** Forwards are rules on the host, edited in its drawer under PORT FORWARDS and shown read-only on its detail pane; they run per terminal, listed with a switch each under FORWARDS in the session sidebar; and the session status bar counts the listening ones as `⇄ N FORWARDS`. No screen of their own, because a forward is not a thing you visit — it belongs to a host and runs with a session, and both of those already have a place. |
> | The host detail's 2×2 card grid — IDENTITY fingerprint, TAGS, LAST SESSION | Not drawn as a grid. There is no fingerprint on a stored key and no last-used timestamp at any layer, so two of the three cards have nothing behind them. Tags are real and are drawn — as chips on the row and a picker in the editor, which is where they are used rather than in a card beside two empty ones. |
> | The keychain detail's public-key block, USED BY hosts, and **Rotate** | The detail pane as it stands. Copy public key is real and already there; rotation is not a thing this client can do. |
> | Logs: **FOLLOW**, the severity chips, and `tailing via ssh · journald + files` | The two logs it really has. Same as the phone — these are synced audit records, not a tail, and the footer sentence describes a feature rather than the screen. |
@@ -102,6 +102,14 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is
> a page test rather than a fourth `ShellSurface`.
>
> **v5b reverses the paragraph above, and says so rather than leaving it to be found out of date.** The tab
> strip this paragraph describes is retired — see the v5b section, below — and with it the rule that made the
> rail conditional at all: the switcher it drew moved onto the nav rail's own head as three segments, the
> rail stopped collapsing for any of them, and neither SFTP, S3 nor a terminal gets the window's full width
> any longer. `IsVaultsTab` outlived the strip essentially unchanged — it is still a page test rather than a
> read of a `ShellSurface`, now over which of the rail's own destinations is showing rather than which of
> three tabs was.
>
> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar
> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds
> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The
@@ -160,11 +168,214 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | **Share this host** | Omitted, as the vault screen's `SHARED WITH · 6` is. A grant is per *vault* and per-item sharing is M5; a button here would imply this one host could be handed over, which is the thing the architecture does not do. |
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
> | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. |
> | The **Backspace / Default** row | Omitted, and it is the one row on this list that could now be built cheaply — a setting, an opcode and a control, the way the text size was. What it lacks is a reason: which byte backspace sends is a fact about the remote's `stty`, so a client-side switch fixes a mismatch by hiding it. See the Preferences section. |
> | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name. A group moves too, from its card's right-click menu, and takes its nested groups and every host filed under them; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. |
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. |
> | **Port Forwarding** in the sidebar | No rail entry; see the FORWARDING row above for where forwards are instead. |
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
>
> ## The desktop's v5 — hosts, restyled, and two reversals
>
> A fifth pass, and the first that undoes as much of its own predecessors as it adds: a re-theme both heads
> share, pinned folders as a full feature, a last-connected time read off the connection log, and two pieces
> of earlier passes put back on the user's own instruction rather than kept.
>
> **The palette went purple, and three fonts are embedded that were only ever named before.** The shared
> scheme moves from v3's near-black-and-blue to a purple accent (`#5D42DE`, with a gradient and a glow of its
> own) over the same near-black canvas, and both heads recolour together — `Palette.axaml` is shared, and a
> colour is not allowed to mean something different on the two of them. What changed on the desktop alone is
> the type: Montserrat, JetBrains Mono and Material Icons are embedded as `AvaloniaResource` in the Shell
> project rather than requested by name and left to whatever the machine happens to have, the way `WithInterFont`
> alone used to leave Inter registered but unused. Montserrat is the default sans now, with Inter kept as its
> fallback rather than the whole answer; `MonoFont` gains JetBrains Mono at the front of its own list, ahead of
> the system-mono stack that is still behind it for a glyph JetBrains Mono does not cover. **Android recoloured
> with the shared palette and kept its own default sans when this paragraph was first written; it does not any
> more** — see "the phone catches up", directly below.
>
> **The phone catches up.** A later reversal than the one above, on the same reasoning: a face is not a
> colour, and the user decided the phone should share both rather than only the second. `WithInterFont` gains
> the same `FontManagerOptions` block `Program.cs` sets, off the same embedded Montserrat, no new asset
> required. `Theme/Phone.axaml`'s six-rung v2 ladder — 4/9/10/11/12/14 — collapses to the desktop's three:
> chip or tag 6, button or field 10, card or output 12; `Button.fab`'s 28 and every sheet's 22 survive
> unchanged, being geometry rather than ladder rungs. `Button.primary` and `Button.fab` take `AccentGradient`
> and `AccentGlow` in place of a flat fill, the same swap `Button.accent` made on the desktop, `BoxShadow`
> cleared the same way on disabled. And the phone's own chrome — the vault header, the bottom bar, the shells
> strip, `HostActionBar`, the editor headers, the terminal's collapsed bar and every screen's raised selection
> bar — moves from `Chrome`/`Sidebar` to `DeepChrome`, joining the desktop's own v5b titlebar-and-rail move;
> `PhoneRail`'s hover takes `Track`, which `Palette.axaml` already names for that row. Borders stay keyed.
>
> **Flat sections replace the grid of group cards and the breadcrumb trail — reversing v3, on the user's own
> approval rather than a defect found in it.** v3's grid held one level of the group tree at a time, opened by
> a double-click, with a trail above it saying where you were; that model is retired outright rather than
> folded into anything; every group is now a heading on one flat board, in label order, exactly the flattening
> the phone's `SidebarRows` has always drawn — a headerless board for a groupless keychain, "No group" first
> where there is one, a chevron per heading and one Collapse All. Nothing here was found broken; the grid did
> what v3 asked for. The user asked for the phone's shape on the desktop too, and decision 2 in
> `hosts-v5-design-spec.md` is that request recorded.
>
> **Tag chips and a mono address line are back on the card — reversing v4, also by approval and not by
> defect.** v4 had replaced both with a two-line `ssh, root, pci, eu-west-1` subtitle and moved the address to
> the tooltip alone, on the reasoning that a card read while scanning forty machines wants the kind of machine
> more than its address. v5's own mock draws chips and an address on every card, and the user chose the mock's
> arrangement over v4's when the two were put side by side — so the card now carries both: the full address is
> still on the tooltip, as v4 left it, and a truncated mono copy sits under the name as well.
>
> **Pinned folders are a full feature, not a field with nowhere to be edited.** `HostSecret.PinnedPaths` is an
> ordered, deduplicated list merged per path the way `TagIds` is merged per tag, so two machines pinning
> different folders on one host both keep theirs. The drawer's QUICK ACCESS section edits it — a row per pin
> with a close box, an add field and button, staged on `VaultViewModel.EditorPinnedPaths` the way the tag
> picker stages `editorTagIds` — the card shows a pin-count badge once there is at least one, and the detail
> pane repeats the list read-only in its own fieldrow idiom. Clicking a pin above a connected terminal opens
> the SFTP tab with the remote pane navigated straight to that path, through the same connect path "Browse
> files" already used — a second authenticated connection, exactly as every SFTP session here is.
>
> **Last connected is read off the synced connection log, and it is deliberately narrow about when.**
> `max(StartedAt)` per `HostId` is computed on the hosts screen's own activation and again when a session ends,
> then restrung into words on a one-minute tick for as long as the screen stays visible — never on the sync
> loop, which is the same rule `LogsViewModel` states for the log itself: nobody is watching their own
> connection from an hour ago update on a screen they are not looking at. A connected host prints nothing here;
> the green dot already answers "right now", and printing an age beside it would be answering a question about
> a session that is not the one still running. **It is scoped to the active vault alone**, the same limitation
> `LogsViewModel` and `MainWindowViewModel.RecentConnections` already carry — a host filed in a second vault
> this session can read gets no ago-text until something on this screen reads more than one vault's connection
> history, which nothing does yet. And a session ending can, briefly, still show the previous entry: the
> recorder's own write is queued onto a background task rather than made inline, so a re-read landing before
> that write drains reads the log as it stood before the session it is reporting on closed. It self-heals on
> the next activation or the next session end, and closing the gap outright was judged not worth blocking a
> screen transition on the recorder's queue.
>
> **Quick connect grew to match, and quietly answers a different question than the mock's own row does.** The
> card widened from 520 to 640, gained the mock's `>_` prompt styling and a live dot per row the way the hosts
> board's own two-state dot works. What it does not draw is the mock's SSH/SFTP kind column — every palette
> connection here is SSH, so a constant printed as though it varied would be exactly the kind of decoration
> this document has refused before (the terminal's session footer, the snippet's kind badge); the auth word
> (credential/key/password) answers a question that is actually different per row instead.
>
> | v5 element | What ships instead |
> | --- | --- |
> | The status dot's third, amber state | ◆ **Still two states, on the same reasoning v3 and v4 both gave and v5 was asked to reconsider.** Green means a terminal is open on that host, grey means one is not; nothing here pings a machine, so the amber "reachable but not connected" state has no fact behind it to draw. |
> | The relay checkbox's mock copy | Restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it). The "(not wired up yet)" it carried while this client could not dial the relay came out when it could; the sentence under it now says what ticking it does — the server dials the host, so the server has to be able to reach it, and the address is stored in plain text. |
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
> | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. |
> | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. |
> | QUICK ACCESS's editor, on the phone | ◆ **Shipped, over the same shared data the deferral above described.** The phone's host editor draws its own QUICK ACCESS section on the same staged `VaultViewModel.EditorPinnedPaths` the desktop's drawer binds — a row per pin, an add field and button, and a remove target sized to this head's 44dp touch floor rather than the desktop's 22-pixel close box. `AddEditorPinCommand`'s refusals surface through a `Status` line the editor page draws for itself, since that page covers the whole screen and the list behind it draws its own `Status` off-screen for as long as it is open. The pins themselves reach a second surface this head has that the desktop does not need: `TransfersViewModel.ConnectedPinnedPaths` carries them as chips on the Files screen while connected, and tapping one runs `GoRemoteCommand` — the same command the breadcrumb trail already used to navigate. Two deviations from the desktop, both named where they land: the chip row is a snapshot taken at connect rather than a live follow of the vault, so a pin edited mid-session shows up on the next connect rather than this one; and the editor's own hint sentence says the pins appear on the Files screen, not above a terminal — this head has no terminal strip for them to sit above, the same honesty the row below already states for the desktop's own hint. |
> | Collapse All beside every section's own collapse chevron | Bound on every heading's view model and shown on only the first — `SidebarGroupHeader.IsFirstBoardSection` is what a virtualised list of sections uses in place of a control of the board's own that would otherwise have to sit above all of them. |
> | The QUICK ACCESS hint's claim that pins live in a sidebar | "Pinned folders appear above the terminal for this host." — no sidebar exists on this screen for the sentence to point at, so the shipped hint says where they actually draw. |
> | The mock's "Saving to **DodoTech ▾** vault" subtitle, with a picker's chevron inside a sentence | The pre-existing `DrawerSubtitle` wording — the vault's name alone, unchanged by this pass. A chevron inside running text implies the text itself is the control, which it is not: the vault picker is its own element, shown only while creating and only above one writable vault, as it always has been. |
> | The design's two deeper text steps, `#6D6F84` and `#5D5F74` | Not added as `Palette.axaml` keys. `TextGhost`, already repicked to `#7C7F98` for v3, is reused everywhere the design reaches for either — a resource nothing yet distinguishes from `TextGhost` is not free to carry, on the same reasoning the border ramp's own remarks give. |
>
> ## The desktop's v5b — chrome, session shell and SFTP restyled
>
> A sixth pass, and the first aimed at the chrome and the two screens somebody spends the most time inside
> rather than at any one screen on its own. **What shipped:** the titlebar and nav rail redrawn against
> `TitleBar.dc.html` and `NavRail.dc.html` — a segmented SSH/SFTP/S3 switcher at the rail's own head, a
> mode-dependent first row beneath it, and Vaults and Preferences moved off the rail's list and into a
> popover under the user chip at its foot. The terminal and the SFTP surface both gained a session shell — an
> in-screen tab row, a host header, a bottom status bar and a 300-pixel QUICK ACCESS/SNIPS sidebar —
> replacing the window-wide tab strip v3 built and the pin-chip strip that used to sit above the terminal
> alone. The SFTP screen's own two panes and its TRANSFERS strip were restyled against `SFTP.dc.html` — row
> grids, quiet Material glyphs, slim progress bars — and three screens that had never had a `.dc.html` of
> their own got one each: Keychain, Snippets (titled Snips on screen now, matching the rail) and Logs.
>
> **The nav rail stopped being conditional furniture, and that is the one structural change underneath the
> paint.** v3 drew it only under the Vaults tab, so SFTP, S3 and an open terminal each got the window's full
> width; v5b's own fidelity pass puts the rail up beside all three, because the design draws it that way and
> because the segmented switcher now living at its head is only worth having if it stays reachable from
> everywhere. `MainWindowViewModel.IsVaultsTab` still exists and still answers the question it always did —
> which of the rail's own six destinations is on screen, as opposed to SFTP or S3 — but nothing about it gates
> the rail's own visibility any more. The window's minimum grew again to hold the difference: `1016x574`
> became `1081x583`, nine pixels for the titlebar's 44-to-53 and sixty-five for the rail's 190-to-255, added
> straight onto the minimum rather than absorbed by shrinking a screen — see `MainWindow.axaml`'s own remark
> and `LayoutHarness.MinimumWidth`/`MinimumHeight`. `ScreenWidth` held at 826 because the rail is the only
> thing beside a page that grew; `ScreenHeight` grew by the 42 pixels the retired tab strip used to cost every
> screen, because nothing replaced that strip as chrome every screen pays for — the terminal and SFTP
> surfaces pay for their own tab row out of their own session-shell budget instead. See
> `LayoutHarness.ScreenHeight` and `SessionScreenHeight`.
>
> | v5b element | What ships instead |
> | --- | --- |
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
> | A **Port forward** button on the host header | **Add forward**, at the foot of the session sidebar's FORWARDS list, which opens the host's editor the way Pin folder does. The header it would have sat on has since moved into that sidebar. |
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
> | The 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 | **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. |
> | Per-tab SFTP sessions, implied by a tab row shared between the terminal and the SFTP surface | **Not built, and not what shipped instead.** A click on the SFTP tab row runs `MainWindowViewModel.SelectFilesHostAsync`, which opens (or reuses) a second, SFTP-specific connection through the same "Browse files" plumbing a pin click already used — an honest second login, not a channel multiplexed onto the terminal's. `SelectedTab` moves with the click, which is also what keeps the sidebar's QUICK ACCESS in step — that list is keyed to `SelectedTab` on both surfaces, so selecting a different terminal tab afterwards can leave QUICK ACCESS naming a host that is not the one the remote pane is actually browsing. |
> | A scroll-to-section affordance behind the sidebar's **+ Pin folder** row | Not built — this application has no way to open the host editor already scrolled to one card inside it. `PinFolderFromSidebarCommand` opens the same three-card editor the hosts screen's own EDIT reaches, at the top, which is the closest honest affordance rather than a new one. |
> | The TRANSFERS strip's aggregate throughput readout (`8.4 MB/s` beside an arrow) | Omitted. `TransferRowViewModel.Progress` computes a rate per transfer; nothing sums those into one number for the whole queue, and inventing one would be exactly the fabricated fact this document's honesty rule forbids. |
> | Freshness/"hot row" colouring and a selection ring on a finished transfer | Not drawn — there is no tracked notion of how recently a row finished, so nothing distinguishes a transfer that just completed from one that finished an hour ago. |
> | The design's Material-Symbols-only "draft" glyph for a file row | `insert_drive_file`, the closest classic Material Icons has — a plain document rather than a page with a folded corner. Recorded as a substitution rather than silently swapped; "draft" is simply not a glyph this font contains. |
> | `REMOTE` across the SFTP panes' arrow column | `HOST`/`BUCKET`, kept from before this pass rather than adopted from the design. The pair is the difference between a directory tree and a flat namespace with inferred folders, which is a distinction worth keeping even where the design collapses it. |
> | `THIS MACHINE` on the local pane | `LOCAL`, the design's own word, adopted — nothing in this file's own comments ever defended "THIS MACHINE" the way HOST/BUCKET is defended above, so there was no reason to keep it. |
> | The Keychain screen's `MODIFIED` column and `FINGERPRINT` box | Both dropped. `VaultItem` carries no timestamp of any kind, and `SshKeySecret` has no fingerprint field — computing one would mean parsing armour the type stores verbatim, an already-recorded gap this pass did not revisit. |
> | Five separate add buttons (GENERATE / + SSH KEY / + PASSWORD / + TAG / + BUCKET) | One `+ New key` accent button, opening a menu of the same five actions in the same order — the design draws one button because its own mock has one "add" concept; this application has five, and none of them was dropped to fit. |
> | `NEW ITEMS GO TO` on the vault-filing picker | `NEW ITEMS FILE TO`, the design's own wording, adopted — this screen's own comments already called the act "filing", so the label was this application's vocabulary already. |
> | The Snippets screen's own on-screen name | **Snips**, matching the rail, which already said so. |
> | A modified-date on a snippet card | Omitted — `SnippetSecret` carries no timestamp, the same absence every other item kind has. |
> | The design's shorter captions — "Type into terminal", the rationale-only sentence for "runs on insert" | The screen's own longer, more actionable wording kept instead: the insert button still names the destination tab (`TYPE INTO {tab}` / `NO TERMINAL OPEN`), and the "runs on insert" caption still states the operational consequence rather than only the reason the setting exists. Both of the design's sentences are true; the ones already here say more. |
> | A delete confirmation for a snippet | **Built, matching the mock.** `SnippetsViewModel.RequestDelete`/`ConfirmDelete`/`CancelDelete` say the same vault-wide-reach, tombstone, no-undo sentence `VaultViewModel.HowFarADeletionGoes` already says for every other item kind — additive beside the existing uncounted `DeleteCommand`, which the phone's own DELETE row still calls. |
> | The Logs screen's own footer sentence about encryption | Adopted verbatim — verified against this screen's own header remark and ADR 0001 before shipping it, both true, so it is drawn as literal text rather than reworded. |
>
> ## The desktop's v5c — a settings area, at last
>
> An eighth pass, and the first to give the design's own Settings area a home of its own rather than folding
> its two real screens into the rail's popover. Settings is a full-window **mode** now, swapped in wholesale
> rather than laid over anything: its own 53px titlebar reading "Back to application", a 340px `SettingsNav`
> rail — SETTINGS (General, Vaults, Account) above CUSTOMIZE (Security, Preferences, Groups, Tags), Logout
> pinned below both — and a content column capped at the design's own 1100 pixels, which is 741 beside the
> rail at the window's own minimum rather than the full 1100 (`LayoutHarness.SettingsContentWidth`). Seven
> pages, not the design's eight: the ORGANISATION section and its one page are refused outright, below. Two
> of the seven are wholly new — Account and Security did not exist as screens before this pass — and the
> other five are the old `PreferencesScreen.axaml` and `VaultsScreen.axaml` split apart and restyled into the
> design's own card idiom; both files are deleted, and every command either one offered is reachable here
> exactly once. The popover's own Settings, Vaults and Preferences rows, and everywhere else in the codebase
> that used to navigate to the bare `Preferences` or `Vaults` screen, now land in this mode instead —
> `MainWindowViewModel.ShowScreen` redirects at that one point rather than at every caller that used to reach
> either screen directly.
>
> The importer moved inside this same chrome rather than staying a screen of its own: it draws over the
> Preferences page as a boolean overlay — `SettingsNav` stays lit on Preferences the whole time it is open,
> and the titlebar's own back button reads "Back to preferences" instead of "Back to application" — restyled
> to the design's own table (ALIAS/HOSTNAME/USER/PORT/WHAT THIS MEANS, one header tick-all button in place of
> the old TICK ALL/TICK NONE pair). Known Hosts was restyled the same pass but kept its own place: it stays a
> main-chrome screen, reached from the rail's Keys entry and now also from Security's own "Approved host
> keys" row, because the design draws no page for it at all. And the S3/Buckets surface — which got none of
> v5b's session shell — picked up that shell's *look* this time without its machinery: a 26-pixel padded,
> bordered, radius-12 container and nothing else, since a bucket has no tab row, no host to head a card with,
> no status-bar fact to print and no pin for a sidebar to show.
>
> | v5c element | What ships instead |
> | --- | --- |
> | The Settings-Organisation page, and the rail's own ORGANISATION section | Refused outright. No organisation entity exists anywhere in this product — a team is the membership list behind a shared vault, and there is exactly one tenant per deployment — so the rail simply has no third section; see `SettingsNav.axaml`'s own remark. |
> | General: the update-channel switcher, Launch at login, Reopen tabs, the theme control and the language picker | Refused, and carried on the page's own NOT BUILT YET card rather than left silently missing: which channel a copy follows is fixed when it is built, nothing registers this application with Windows' own startup list or remembers a tab list across a launch, only the one dark theme exists, and there is no i18n anywhere in this client. |
> | Vaults: the VAULT DEFAULTS card (auto-lock, require-password-on-unlock, relay) and the RECOVERY card (kit, export) | Both refused outright — none of the three settings exists, sync is always on, and there is no recovery kit and no export. |
> | Vaults: "Manage devices", and the "3 devices" count on the sync line | Refused. There is no list-devices endpoint anywhere in this client. |
> | Vaults: per-vault UNLOCKED/LOCKED chips, and the "Unlock ⟨vault⟩" modal | Refused. This application locks the keychain as a whole, not one vault at a time, so there is no per-vault state for a chip or a modal to act on; the keychain-level fact is the sync card's own dot instead. |
> | Vaults: the magenta "Default" badge | Dropped rather than faked. The app does track a "new items go to" vault, but that lives on a different view model than the row being drawn here, and cross-referencing the two per card would be more machinery than the badge is worth. |
> | Vaults: "Sorted by name ▾" | Decorative in the mock, and not drawn — `VaultsViewModel.Vaults` is already ordered personal-first-then-name, and there is no second order to switch to. |
> | Vaults: member avatar stacks on every card | Refused on an unselected card — `VaultRowViewModel` carries a member *count*, not the members themselves, and only the selected vault's own member list is actually loaded. A count stands in on the card; avatars are real in the members panel, where the data is. |
> | Account: Edit profile, Change passphrase, the DEVICES list, Sign out everywhere | All four refused — no endpoint exists for any of them, and the passphrase is a one-time enrollment choice with no change flow. What is new and real on this page instead: the SIGN-IN card's issuer sentence, `MainWindowViewModel.Issuer` off `MeResponse.Issuer`, cached the same turn the name and email are and surfaced here for the first time — "Signing in proves who you are — it never decrypts a vault." |
> | Security: the strict-host-key toggle, the allowed-algorithms chips, the auto-lock link row | Refused — the app always asks on a changed key, there is no algorithm allow-list anywhere in the SSH stack, and there is no auto-lock setting for the link to point at. |
> | Security: RECENT SECURITY EVENTS as a list of rows | Shipped as the link alone, "See all activity in Logs." A real list would mean a filtered read over `LogsViewModel`'s two logs, and which entries count as "security" is a judgement call the design does not resolve; the honest link is complete on its own. |
> | Preferences: the device-name editor, Font/Cursor/Scrollback/Copy on select/Terminal bell, the clipboard-clear delay, confirm-run-on-insert | All refused, on the page's own carried-over NOT-BUILT idiom — none of the seven has anything behind it. Windows Hello's register/"Stop unlocking here" pair moved off this page to Security instead, one home rather than two. |
> | Groups: drag-to-reorder, and the mock's "the order here is the order there" sentence | Refused. `HostGroupRowViewModel` orders by the vault new items go into, then by vault name, then by label — there is no manual order to drag into. The page prints the true sentence instead and draws no drag handles. |
> | Tags: the LAST APPLIED column | Refused — no timestamp of when a tag was last put on a host exists anywhere in this client. |
> | Tags: a per-tag vault chip | Refused — `TagRowViewModel` carries a label and a host count and nothing else; there is no vault id on the row for a chip to read. |
> | Import: the "saving to ⟨vault⟩ ▾" picker | Refused as a control. Every import writes through the same `session.ActiveVaultId` every other bulk write does, so there is no per-import target to choose — the footer prints the vault's name as a fact, `ImportViewModel.SelectionSummary`, rather than as a `▾`. |
> | Import: the old ADDRESS/AUTHENTICATION/STATE columns | Folded into the design's own column set — ALIAS/HOSTNAME/USER/PORT — with the authentication text moved onto the alias cell's own tooltip rather than kept as a column of its own. |
> | Import: TICK ALL / TICK NONE | Replaced by the design's own header tick-all button, one press that ticks or unticks every row through `ImportRowViewModel.ToggleAllCommand`. |
> | Import as a nav-rail destination | Refused. It is a boolean overlay over the Preferences settings page (`MainWindowViewModel.IsImportOpen`), not a page of its own — `SettingsNav` stays lit on Preferences throughout, exactly as `Import.dc.html` draws it. |
> | The design's fixed 1100px content column | `MaxWidth="1100"` rather than a fixed width — the column is 741 pixels beside the 340px rail at the window's own minimum, where a fixed 1100 would not fit. |
> | The status bar and the update banner, while in settings mode | Both hidden — `MainWindow.axaml`'s bottom two rows read `!IsSettingsMode`. The design's own settings titlebar has no room for either, and there is nowhere honest to draw them instead. |
> | The design's sentence-case button copy | Not adopted. Every button on every settings page keeps this application's own ALL-CAPS mono convention — `CHECK NOW`, `SIGN OUT`, `OPEN IMPORTER` — over the mock's own "Check now". |
> | The design's assumption that Settings is the only thing on screen | Not followed. The quick-connect palette and an unapproved host key's decision card both still draw over settings mode exactly as they interrupt every other screen — a connection question does not stop mattering because the window happens to be showing Settings; see `MainWindow.axaml`'s own remark on the ordering of its Panel. |
> | The importer's own sentence, "This is the only control in DodoSSH that opens key material from a directory you did not point at file by file — nothing is read until Import is pressed." | Adopted verbatim — verified against `ImportViewModel.ScanAsync` and `ImportAsync`: scanning never calls `SshConfigLocator.ReadIdentity`, and `ImportAsync` is the only path that ever does. |
> | The tags page's own sentence, "Renaming here is one write and every host follows." | Adopted verbatim — verified against `SaveTagAsync`: a host names a tag by id, never by label, so a rename touches nothing but the tag item itself. |
> | The known-hosts screen's own intro, "Every pin is a decision recorded at the moment of connecting. Fingerprints are never trimmed — compare them character by character against what the operator published." | Adopted verbatim, matching the stance the rest of this application already takes on a pin. |
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
project each piece would have to land in, and **what the shipped interface does instead**. That last
@@ -173,16 +384,18 @@ it for revocation, for relay session recording and for what locking does not do
rendered with invented data to make a screenshot look finished. Where a feature does not exist, the
interface either omits it or says so.
Read alongside [the milestone plan](../README.md#milestones). Most of what follows is not a defect; it is
M2 and M3 arriving in a design before it arrives in the code.
Read alongside [the milestone plan](../README.md#milestones). Most of what follows is not a defect. M2 and
M3 have since arrived and their rows say so; what is left is either M5 — per-item content keys, and the
sharing that would rest on them — or a decision recorded here not to build the thing at all.
## The shape of the problem
Three facts explain nearly every row below.
**An SSH connection here opens exactly one channel.** `ISshConnection` offers `OpenShellAsync` and nothing
else (`src/DodoSSH.Client.Ssh/SshConnection.cs`). No port forwarding, no ProxyJump. That one fact removes
the `FORWARDS` chip, the status bar's port list, and every `via bastion-eu` in the design.
**An SSH connection here opens a shell, and forwards beside it.** `ISshConnection` offers `OpenShellAsync`
and `StartForwardAsync` (`src/DodoSSH.Client.Ssh/SshConnection.cs`). There is still no ProxyJump, which is
what removes every `via bastion-eu` in the design. Port forwarding arrived later than most of what this
document accounts for, and the rows about it now say where it went.
It used to remove the file-transfer screen too. M2 did not lift the restriction — it worked around it:
file transfer is a *separate connection* rather than a second channel, because SSH.NET's `SftpClient` owns
@@ -204,9 +417,12 @@ reason and is gone: membership is granted only to an account that exists, so the
reservation fails to cover. The status stays reserved, because the column holds it in nobody's database and
a client must not fail on a value a later server might send. See [ADR 0009](adr/0009-team-access-model.md).
**The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the
cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be
saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway.
**The client's settings file holds two things.** It writes three files — `cache.db`, `device.key` and
`settings.json` — and the cache's six tables still hold none of them. `ClientSettings` carries the
terminal's text size and whether this machine looks for a newer build on its own, and both survive a
restart. Everything else on the design's TERMINAL panel is unbuilt rather than unbuildable: the data plane
grew `TerminalServerOpcode.FontSize` to carry the one that shipped, so a second option is an opcode, a
setting and a control rather than a new mechanism.
**Three things the design did not ask for and this build now has.** A key can be generated in the client
rather than pasted in (`SshKeyGenerator`, and the `openssh-key-v1` container is written by hand — see
@@ -246,8 +462,10 @@ configuration, a server kind that refuses every plaintext field, a codec, a merg
same way, for the same cost, and is the clearest case the reservation ever paid for: a whole item kind, a
table, a migration and an AAD arm, with `PublicAPI.Unshipped.txt` untouched.
`HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` is now
reserved *against a decision* rather than pending one. Which hosts wear a tag is a set of ids inside each
`HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` and
`PortForward` are now reserved *against a decision* rather than pending one. Port forwards shipped inside the
host's payload, for the reason `RelayTicketRequest.PortForwardId` already assumed and the one below gives for
tags. Which hosts wear a tag is a set of ids inside each
host's payload, not a join: the one thing a join buys over that is two machines tagging the same host
without either losing, and the client's per-key three-way merge already gives it. A second item type would
be a second table and a second pass through every rule in this file, for nothing.
@@ -294,10 +512,10 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
| `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. |
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". |
| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. |
| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. |
| Status bar port forwards | client-ssh | — | **Shipped** on the session status bar as a count of listening forwards; the ports themselves are in the session sidebar. |
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. |
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | Inter (already embedded) for prose, and the system monospace stack the terminal already names. Named once in `App.axaml` as `MonoFont`, so the substitution is reversible in one place. |
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | **Shipped, since v5, and not IBM Plex.** The v5 design asked for Montserrat, JetBrains Mono and Material Icons instead, and all three are embedded as `AvaloniaResource` in the Shell project rather than requested by name and left to whatever the machine happens to have. Montserrat is the default sans, with Inter — the earlier substitution — kept only as its fallback; `MonoFont` puts JetBrains Mono first, ahead of the system-monospace stack the terminal still falls back to. Named once each in `Program.cs` and `Palette.axaml`, so a later design's fonts are still a one-line swap. |
| `⌘K`, `⌥↵` | ui | Nothing; the design is Mac-flavoured. | `CTRL K`. Development is Windows-only today (`docs/platform-flags.md`). |
**Also worth knowing:** custom chrome went in as `SystemDecorations="BorderOnly"`, not by extending the
@@ -318,15 +536,15 @@ caption buttons and window title drawn on top of the application's own — two s
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
| `⇄ FORWARDS · 2` | client-ssh | Port forwarding. `SyncEntityType.PortForward = 9` is reserved; nothing in the SSH layer forwards anything. | Omitted. |
| `⇄ FORWARDS · 2` | client-ssh | — | **Shipped**, as `⇄ 2 FORWARDS` on the session status bar, counting only forwards actually listening. |
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. |
| Pane header `aes256-gcm` | client-ssh | **No longer a miss — the plumbing this row used to lack now exists.** `ISshConnection.Cipher` and `TerminalWorkspace.GetSessionFacts` were built for the v5b session shell's status bar; see that row in the v5b section, above. | Still not on a pane header — this pre-v5b element does not exist as its own piece of chrome any more. The fact it wanted to show is drawn instead where v5b moved it: the status bar's `SessionCipher`, beside CONNECTED. The session shell's host header separately shows the account and endpoint actually dialled (v3–v4: the tab strip did; v5b moved that fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
| Terminal font size (`--termfs`, 11–16px) | client-storage | See preferences. | Fixed at the renderer's 13px. |
| Terminal font size (`--termfs`, 11–16px) | client-storage | — | **Shipped**, and wider than the design's range: 8–32px from the preferences screen or Ctrl+plus/minus/0 over a terminal, carried by `TerminalServerOpcode.FontSize` and kept in `settings.json`. It resizes the grid rather than magnifying it, so every live pane refits and the remotes are told how many columns they now have. |
---
@@ -350,11 +568,11 @@ connect, and it goes through the same host key gate, the same pin and the same t
| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | All four. `PosixMode` renders `drwxr-xr-x` from the bits SFTP hands over; setuid, setgid and sticky are not shown, because SSH.NET does not surface them and `rwx` where `rws` is true would be worse than nothing. |
| Local listing | client-transfer | `LocalDirectory`, which is where this client's `System.IO` now lives. `PERMS` is blank on the local side rather than filled with a plausible-looking POSIX mode that is not a fact about a file on Windows. |
| Transfer queue, progress, throughput | client-transfer | `FileTransferQueue`. One transfer at a time, so the rate on a row is the rate of the link rather than a share of it. Throughput is measured over a half-second window, not averaged since the start. |
| `resume supported` | client-transfer | **Within a run of the application.** Every transfer writes to a `.dodossh-part` file beside its destination and is renamed into place at the end, so an interrupted one can never be mistaken for a finished one, and `RESUME` carries on from the part file's own length. A part file found at startup is *not* resumed: nothing records what wrote it, and resuming on the strength of a name matching is how a corrupt artefact gets delivered with nothing reporting a failure. Making it survive a restart needs the preferences store this client has not got — see below. |
| `resume supported` | client-transfer | **Within a run of the application.** Every transfer writes to a `.dodossh-part` file beside its destination and is renamed into place at the end, so an interrupted one can never be mistaken for a finished one, and `RESUME` carries on from the part file's own length. A part file found at startup is *not* resumed: nothing records what wrote it, and resuming on the strength of a name matching is how a corrupt artefact gets delivered with nothing reporting a failure. Making it survive a restart needs somewhere to record *what* wrote each part file — the source, the offset and the run — which is a table rather than a setting; `settings.json` holds two scalars and is not that. |
| Design element | Layer | What it would take | What ships instead |
| --- | --- | --- | --- |
| Per-host last directory | client-storage | Somewhere to persist two panes' navigation state. There is still no settings table. | The remote pane opens on the account's home directory, which the server canonicalises during the handshake; the local pane opens on the user profile. |
| Per-host last directory | client-storage | Somewhere to persist two panes' navigation state per host, which is a table rather than a scalar — `settings.json` holds two of those and is the wrong shape for a row per machine. | The remote pane opens on the account's home directory, which the server canonicalises during the handshake; the local pane opens on the user profile. |
| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. `HostSecret.JumpHostIds` is still stored, synced, merged and read by nothing. | Omitted. |
| Overwriting a file that is already there | ui | A prompt, which means a modal this window has no idiom for. | Refused, with the name that is in the way. The remote pane has DELETE and MKDIR so the refusal is not a dead end. |
| Dragging between the panes | ui | Drag-and-drop between two `ListBox`es, plus a drop target that is a directory rather than a row. | Two arrow buttons between the panes, pointing at the pane the file is going to. |
@@ -476,8 +694,8 @@ lists the rest as absent rather than omitting it silently.
| Design element | Layer | What it would take |
| --- | --- | --- |
| Any preference, saved | client-storage | A seventh table in the client cache, or a preference item type in the vault. Which one matters: the vault syncs, so a preference stored there follows you between machines, and a terminal font size probably should not. |
| Terminal font, size, cursor style, cursor blink, scrollback | client-terminal | The store, **and** a frame to carry it. `TerminalServerOpcode` has four values (`Output`, `SessionOpened`, `SessionClosed`, `SessionActivated`) and none carries an option. |
| Any preference, saved | — | **Two are.** `settings.json` beside the cache holds the terminal's text size and whether this machine checks for updates on its own. It is deliberately not in the vault: the vault syncs, so a preference kept there would follow you between machines, and a font size chosen for a laptop screen should not arrive on a desktop. Anything per-host or per-item wants a table instead — see the transfers rows above. |
| Terminal font, cursor style, cursor blink, scrollback | client-terminal | A setting, an opcode and a control, in that order — the mechanism is no longer the obstacle. `TerminalServerOpcode.FontSize` proved the path and `ClientSettings` is where the value would live; what none of these four has is somebody deciding it is worth a row on the preferences screen. |
| Font family picker offering IBM Plex Mono | ui | Shipping the font, as above. |
| Audible bell | client-terminal + ui | xterm.js 6 removed `bellStyle` and `bellSound`; what remains is an `onBell` event, so the page would have to make the sound itself. |
| `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. |
@@ -505,3 +723,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.
+1477 -158
View File
File diff suppressed because it is too large. Load diff
+139 -5
View File
@@ -3,8 +3,18 @@
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
have already cost time once. Development is Windows-first, but **the full test suite now runs on
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
suspicion. **macOS is still untested**, and anything marked *unverified* has not run on the platform
in question and must not be assumed to work.
suspicion. Anything marked *unverified* has not run on the platform in question and must not be
assumed to work.
**macOS now builds and packages, and has still never run.** The distinction matters more here than
anywhere else on this page, because the two halves are verified in completely different places. The
build is measured on every main and tag build: CI publishes `osx-arm64` and runs `vpk [osx] bundle`
on a Linux runner, which is enough to catch a restore graph with no macOS native asset and an `.app`
that will not compose. Everything past that — whether the window draws, whether the terminal's
loopback WebSocket reaches WKWebView, whether the Secure Enclave holds a device key — is verified
only by a person walking Phase 18 of [manual-checks.md](manual-checks.md) on a Mac, because **there
is no macOS runner in CI**. Treat every macOS runtime claim below as unverified unless it says
otherwise.
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
has been verified or made moot — not when it merely stops being convenient.
@@ -17,6 +27,19 @@ docs/crypto.md §1. *Already mitigated* — but if a BCL AEAD path is ever added
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
any vault on macOS.
**The Secure Enclave holds P-256 keys and nothing else**, which is why `MacDeviceKeyStore` wraps the
device key with ECIES rather than with the RSA-OAEP the Windows store uses. It will not hold an RSA
key at any size, so this is not a preference. The useful consequence is that the macOS shape is
*better* than the Windows one: `SecKeyCopyPublicKey` works on an enclave key without prompting, so
registering a device is silent and only unlock asks — where Windows raises a dialog at key creation
too. *Unverified:* no enclave call in this repository has ever run.
**Three ordinary Macs have no usable enclave**, and `IsSupported` probes rather than infers for that
reason: an Intel machine without a T2, a machine with no login password set, and — the one that
surprises people — **any build that is not code signed**, because enclave key creation needs a
signing identity. So `dotnet run` correctly offers no device key at all. Do not "fix" this by
checking the OS instead; the offer would then put a wrap on the server that nothing can ever open.
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
@@ -26,7 +49,11 @@ and the parameters are stored per user at enrollment, so a bad default is a per-
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
or notarization fails with an error that does not name the offending file.
or notarization fails with an error that does not name the offending file. *Mitigated* in
`scripts/release-macos.sh`, which signs every `.dylib` and `createdump` in a loop before vpk touches
anything — vpk's own pass uses `codesign --deep`, which is the shape Apple documents as wrong for
nested code and is the likeliest source of that unnamed rejection. The loop looks redundant next to
`--deep` and is not; do not delete it because a release once succeeded without it.
## Desktop client
@@ -361,11 +388,55 @@ 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
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
**The App Sandbox is ruled out on macOS for the same reason, and the entitlements say so.** A
sandboxed process cannot listen on loopback without `com.apple.security.network.server`, and the
terminal is that listener. Developer ID distribution outside the App Store does not require the
sandbox, so this costs nothing today — but it does mean the Mac App Store is closed to this
application without solving the data plane differently first. See
`build/macos/DodoSSH.entitlements`.
**The hardened runtime is not optional and .NET needs four holes punched in it.** Notarization
refuses a Developer ID submission without it, and CoreCLR will not start under it without
`allow-jit` and `allow-unsigned-executable-memory` — both, not either, because the runtime allocates
executable memory outside the `MAP_JIT` path as well. `disable-library-validation` and
`allow-dyld-environment-variables` are needed for Velopack's updater rather than for the runtime.
Each is argued individually in the entitlements file; the failure mode for a missing one is a
process that dies during runtime initialisation, before anything exists that could report it.
**`vpk` cross-compiles to macOS only as far as the bundle.** `vpk [osx] bundle` runs anywhere and
produces a real `.app`; there is no `[osx] pack` off a Mac, because pack drives `codesign`,
`notarytool` and `stapler`. So CI can prove the bundle builds and only a Mac can produce something
installable. Note this is the *opposite* of the Windows story, where `vpk [win] pack` builds the
whole installer on Linux — the asymmetry is Apple tooling, not a Velopack limitation.
**A custom `Info.plist` is copied verbatim by vpk, with no substitution whatsoever.** That is why
`--plist` and `--bundleId` are mutually exclusive, and why `build/macos/Info.plist.template` is a
template the release script renders rather than a committed file. A committed plist would carry one
version into every release afterwards, and the symptom is silent: Velopack's index would still be
right, the updater would still work, and only Get Info and any crash report would disagree.
**macOS app icons live on an 824-in-1024 grid.** An icon that bleeds to the edge of its canvas is
not bolder, it is the one icon in the Dock that is too big. `dodossh-icon.ps1` draws the `.icns` at
that fraction and the `.ico` at full bleed, from one geometry.
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
@@ -568,12 +639,51 @@ Packaging the desktop client had broken the server's image build, and nothing bu
have caught it. Found by running `docker build` locally rather than by reading the lock files.
So the RID stays out of the committed state, and the two commands that need one — the release script's
publish and the `windows publish still resolves` step in `ci.yml` — pass `-p:RestoreLockedMode=false` for
themselves alone. That restore rewrites the lock files as a side effect, which does not matter on a runner
publish and the `publish the windows desktop client` step in `ci.yml` — pass `-p:RestoreLockedMode=false`
for themselves alone. That restore rewrites the lock files as a side effect, which does not matter on a runner
whose checkout is discarded and does matter on a developer's machine, so the release script runs
`git checkout -- '*packages.lock.json'` afterwards. `-p:RestorePackagesWithLockFile=false` is not an
alternative: it fails NU1005 whenever a lock file already exists.
**`vpk` picks its target from the host, and cross-compiling is a bracketed directive rather than a flag.**
The CI job packages the Windows desktop client on a Linux runner, and plain `vpk pack --runtime win-x64`
there refuses outright:
```
To build packages for Linux, the target rid must be Linux (actually was Windows). If your real intention
was to cross-compile a release for Windows then you should provide an OS directive: eg. 'vpk [win] pack ...'
```
The directive goes before the verb — `dotnet vpk '[win]' pack …` — and must be quoted in a POSIX shell,
where `[win]` is a glob matching any one of `w`, `i` and `n`. With it, a Linux runner logs
`Directive enabled for cross-compiling from Linux (current os) to Windows` and writes
`DodoSSH.Desktop-win-Setup.exe`, the portable zip, the `.nupkg` and `releases.win.json` — the same set a
Windows machine produces. `--runtime win-x64` is still required: the directive says which OS is being
targeted, not which RID. Only signing needs Windows tooling, which is why `ci.yml` can package and
`scripts/release-windows.ps1` will still be the thing that signs when there is a certificate to sign with.
**`dotnet msbuild -getProperty:Version` answers `1.0.0`, and MinVer is not to blame.** `-getProperty`
without a target *evaluates* the project and runs nothing, while MinVer computes the version inside a
target — so the read comes back as the SDK default on a full checkout with every tag present, which looks
exactly like a version that was never configured. `-t:MinVer` makes `-getProperty` report the value after
that target has run, and every reader of it — the tag check in `ci.yml`, the desktop nightly job, and
`scripts/release-windows.ps1` — passes it. Neither of the first two had, and neither had ever run: the CI
check is `if:` a tag ref and there are no tags yet, so the first release would have been refused by its own
guard, which would have blamed `fetch-depth`.
**And naming that target requires a restore first, which is a second failure wearing a very different
face.** MinVer arrives as a package, so its target is imported from `obj/*.nuget.g.targets` and does not
exist at all on a clean checkout:
```
error MSB4057: The target "MinVer" does not exist in the project.
```
That reads like a typo in the workflow rather than like a missing restore, and it does not reproduce on any
machine that has built the project before — which is every developer machine and no fresh runner. The
build job's tag check is safe because it runs after that job's own restore; the desktop nightly job and the
release script each restore before reading, deliberately and with a comment saying why.
**The Android head's lock file is outside the solution, so nothing checks it until the android job runs
— and the android job was broken for an unrelated reason for the whole of the release that went stale.**
`DodoSSH.Client.Android` is deliberately not in `DodoSSH.slnx` (it needs a workload the other two jobs
@@ -602,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.
+314
View File
@@ -0,0 +1,314 @@
# Reaching a host you cannot dial
Some machines do not answer from where the user is sitting. This product has **two** answers to that, and
one of them works.
- The **relay** pipes raw TCP through the deployment. Both halves are built: the server issues a ticket and
pipes a WebSocket to the host, and the client reaches it through a loopback SOCKS5 bridge. Step 3 below.
- A **jump host** reaches the target through a machine already in the keychain. `HostSecret.JumpHostIds`
stores the chain; nothing writes it and nothing reads it.
◆ **An earlier version of this page said the relay's server half was "built and shipped". It was not.** What
existed was ADR 0004, the contract records, the options and the CHECK constraint — and `RelayOptions` said,
in its own remarks, that there was no endpoint and no dialler. Both halves were built together at step 3.
This document is the comparison between them, which is the thing that has to be settled before either is
built, and then the plan. It replaces an earlier draft of `docs/jump-hosts.md` that recommended deleting the
jump chain — see the last section for why that was wrong.
> **Status: steps 0, 1 and 3 done, and step 4 for the relay. Jump hosts are next.**
>
> | Step | State | Notes |
> | --- | --- | --- |
> | 0. Stop promising the relay | **Done, then retired at step 3** | The "not wired up yet" sentence came out of both heads when the relay started working |
> | 1. Dial through a loopback proxy | **Done** | `SshLoopbackProxy` on the request, honoured in `BuildConnectionInfo`. **Not the bridge** — see below |
> | 2. Jump hosts over it | Not started | No server change, and no bridge either: SSH.NET's own forward is the proxy |
> | 3. The relay: the bridge, then the ticket | **Done** | Server: `Features/Relay`. Client: `RelayBridge`, `ServerRelay`, and `VaultViewModel.OpenThroughAsync`. See "What step 3 built" |
> | 4. File transfer parity | **Done for the relay** | `TransfersViewModel` opens a relay for a relayed host exactly as the shell does. A jump chain will need the same line when step 2 lands |
## ◆ The relay's checkbox is a false promise, and that is a defect
`HostSecret.RelayEnabled` is stored, validated — a relay host may not inherit its port — encoded, merged,
and drawn as a checkbox in the host editor on **both** heads. The desktop's says *"Connect through the
server relay"* and warns underneath that the address will be stored on the server in plain text. The
phone's says the same at more length.
Nothing on the client reads it. `VaultViewModel` builds `SshConnectionRequest(hostname, port, username,
credential)` and `SshNetConnectionFactory` dials that address directly, whether the box is ticked or not.
So a user who ticks it **pays the privacy and gets nothing**: the host's address and port leave the
encrypted payload and land in plaintext columns on the server — the one deliberate concession in the whole
design, per ADR 0004 — and the connection is still made from their laptop to the machine they already could
not reach. It then fails exactly as it did before, with no hint that the box did nothing.
This is worse than the jump chain, which is invisible and harmless. It is a control that spends something
real.
**Step 0 is done.** Both heads now name the absence in the label and lead the paragraph under it with what
ticking the box actually does today — the way this codebase already handles port forwarding on the phone's
More screen. It is left *tickable* rather than disabled, deliberately: a host that already carries the flag
has to be able to lose it, and a disabled control would trap the concession on. The sentence comes out again
at step 3.
## The comparison
Both answers put something between the user and a machine they cannot dial. What differs is *what* is in
between, what it costs, and who has to own it.
| | Relay | Jump host |
| --- | --- | --- |
| **Reaches** | Anything the **deployment** can reach | Anything a **machine already in the keychain** can reach |
| **Asks of the deployment** | It must sit where it can dial the target, and have the relay enabled | Nothing. The server is not involved at all |
| **Tells the operator** | The host's address and port, in plaintext columns, for every opted-in host — plus an audit row per session: target, duration, bytes, close reason, client IP | Nothing beyond the sync metadata every item already produces |
| **The intermediate's credentials** | None to manage. The deployment is the intermediate | The bastion is an ordinary host: its own key or password, its own host key to pin, its own group defaults |
| **Where SSH terminates** | On the laptop. The relay sees ciphertext, and ADR 0004 is emphatic that no recording is possible | On the laptop. The bastion forwards a TCP stream inside a session the user opened to it |
| **When it is unavailable** | Deployment down, no connection — including to hosts that were reachable directly | Bastion down, no connection to what is behind it |
| **Fits an estate where** | The DodoSSH server is *inside* the network the targets are on | A bastion is the policy and the server is outside — which is the ordinary enterprise shape |
| **Auditable by the operator** | Yes, coarsely, and that is a feature for a team deployment | No, and that is a feature for a private one |
**The two are not substitutes, and the deciding question is where the deployment sits.** The relay only
answers "unreachable" when the server has line of sight the laptop lacks — a deployment inside the VPC, on
the office network, on the same Tailnet. Point it at a self-hosted box outside the target's network, which
is what most people running this on a VPS will have, and the relay reaches nothing the laptop could not
already reach.
**And you do not get to choose other people's topology.** Shipping this to strangers means shipping into
estates whose shape is already decided, and bastion-fronted is the common one. Their `ssh_config` says so:
the importer reads `ProxyJump`, records it as an option and writes a note on the host saying *"DodoSSH does
not route through a jump host yet"* — a first-run experience that names the limitation on the hosts it
matters for.
**A relay is not a bastion with better manners.** ADR 0004 rejected "server terminates SSH" and kept
zero-knowledge, which is right and is not what a jump host asks for either: forwarding a TCP stream through
a machine the user has authenticated to reveals nothing to the operator, because the operator is not in it.
The privacy ordering is the opposite of what the ADR's framing suggests — the relay is the mechanism that
costs a plaintext address, and the jump host is the one that costs nothing.
## They are one piece of work, and ADR 0004 says so
The last consequence in ADR 0004, written before either half was built:
> On the client, SSH.NET cannot be handed a pre-connected stream, so the relay is reached via a loopback TCP
> bridge. The same bridge provides ProxyJump via a SOCKS5 dynamic forward — **one mechanism, two features**.
That is the plan, and it holds up against the pinned package. SSH.NET 2025.1.0 offers
`ForwardedPortDynamic`, which is a SOCKS5 proxy served over an established `SshClient`, and
`ConnectionInfo(host, port, username, ProxyTypes, proxyHost, proxyPort, proxyUsername, proxyPassword,
AuthenticationMethod[])` with `ProxyTypes.Socks5` — checked in `Renci.SshNet.xml` rather than remembered. So:
- **Jump host:** connect to the bastion as an ordinary host, `AddForwardedPort(new ForwardedPortDynamic(0))`
on it, then dial the target with a `ConnectionInfo` pointed at that loopback SOCKS5 port. A chain of two
is the same trick twice.
- **Relay:** the same shape with a different thing on the loopback socket — a listener that pipes bytes into
the `dodossh.relay.v1` WebSocket instead of into a bastion's forward.
### ◆ But the bridge is the relay's half, not the shared one
Written into step 1 of this plan and wrong. Building it turned the sentence around: **the jump-host path
needs no bridge from this repository at all.** `ForwardedPortDynamic` *is* the loopback listener — SSH.NET
accepts on it, speaks SOCKS5 on it, and tunnels what it accepts through the bastion. Nothing is left for a
bridge of ours to do. The relay is the case with no `SshClient` to hang a forward off, so it is the one that
needs a listener written here, and it is the bridge's only consumer.
What the two genuinely share is one step lower: **the connection being told to reach its target through a
loopback SOCKS5 proxy, while staying about the target**. That is the piece both features stand on, it is
fifteen lines in `BuildConnectionInfo`, and it is what step 1 turned out to be.
So the bridge moves to step 3 and lands with the feature that uses it. Building it now would have been a
component whose only caller was two steps away — which is the shape of the two defects this document is
about.
## The work, in order
**0. Stop promising the relay.** ✅ The checkbox states that the relay is not wired up yet. One line on each
head, and the only step that should ship on its own.
**1. Dial through a loopback proxy.** ✅ `SshConnectionRequest` carries an optional `SshLoopbackProxy`, and
`BuildConnectionInfo` builds SSH.NET's proxy `ConnectionInfo` when it is there. Three properties are worth
knowing, and each is held by a test in `LoopbackProxyTests`:
- **The type is a port and nothing else.** A proxy on any interface but loopback cannot be expressed, which
matters because the failure mode is an open SOCKS proxy on the user's network for the life of a shell,
and nothing would report it.
- **SOCKS5 rather than a dumb pipe, so the target stays the target.** The host and port in the request are
the ones SSH.NET dials *through* the proxy and the ones the host key gate pins — so the same machine
reached through a bastion is pinned under its own name, not under `127.0.0.1:<ephemeral>`, which is not
an identity at all.
- **A proxy that is not listening fails as a connection error**, not as an unknown host key. The gate
translates "no host key seen" into a fingerprint prompt, and a connection that never reached a server has
seen none either — so the prompt would offer to fix the wrong thing, with no fingerprint to show.
Nothing calls it with a proxy yet. That is deliberate and it is one step wide: step 2 is the caller.
**2. Jump hosts.** No server change, and no bridge. In order:
- The hop's own connection, and a `ForwardedPortDynamic("127.0.0.1", 0)` started on it, whose `BoundPort`
becomes the `SshLoopbackProxy` for the connection after it. A chain of two is that twice.
- `VaultViewModel` resolves `JumpHostIds` to hosts in the same vault, applying group inheritance per hop the
way the target already gets it, and refuses a chain that crosses a vault — the same refusal the group
picker already makes, for the same reason.
- ◆ **The target's address is resolved at the last hop, not here.** A SOCKS CONNECT names the target and the
bastion resolves it, so what has to be stored on the host is the address *the bastion* can reach — which
is what an `ssh_config` means by `HostName` beside a `ProxyJump`, and what the importer is already
carrying across verbatim. Nothing needs to change for that to be true; it needs to be said, because a
host that resolves here and not there fails as a SOCKS "general failure" naming neither end.
- Per-hop host keys. Each hop is a separate handshake against a separate endpoint, so the pin, the unknown
key prompt and the changed-key refusal run per hop. **The prompt has to name which hop it is about**, or
somebody approves a bastion's fingerprint believing it is the target's — see `HostKeyCard`, which is built
around one connection and one question.
- Per-hop credentials, including a hop that wants a typed password. `IsAskingForConnectPassword` asks about
one host today.
- Teardown: the hops belong to the outer session and go with it, including when the outer connect fails
half way. A leaked bastion connection is an open session on a machine the user believes they left.
- The schema version. A chain becomes a real field, so it joins the ladder in `HostSecretCodec` — a host
carrying one must not be editable by a client that would drop it. That is the whole point of the rule.
- The editor: a picker over other hosts in the same vault, and the host detail's subtitle finally getting
the `⤷ bastion-eu` the design asked for.
**3. The relay, and the bridge with it.** A loopback `TcpListener` on an ephemeral port that accepts one
connection, answers a SOCKS5 CONNECT on it, and pipes the rest into the `dodossh.relay.v1` WebSocket —
SOCKS5 rather than a raw pipe so that this path presents the same interface step 1 already speaks, and the
target's identity stays the target's. Then `POST /relay/tickets` with the host id and the WebSocket with the
ticket in `Sec-WebSocket-Protocol`; the ticket is single-use and expires in 30 seconds, so it is fetched per
connect and never cached. The bridge binds `127.0.0.1` explicitly, accepts once and stops listening. Then
the sentence step 0 added comes out of both heads.
**4. File transfer.** `ISftpSessionFactory.OpenSftpAsync` opens its own second connection, so a host that
needs a chain or a relay to reach needs it there too, or SFTP silently fails for exactly the hosts this
work exists for. ✅ for the relay.
## What step 3 built
**The server**, in `DodoSSH.Api/Features/Relay`:
- `POST /api/v1/relay/tickets` makes every decision. It finds the host, requires Connect on its vault and
the relay switched on, resolves the stored name, and runs every answer through `RelayAddressPolicy` —
the non-overridable list first, then the operator's denials, allow-list and private-network switch. What
passes goes into a Data Protection ticket; the name is not resolved again. **Every target refusal is one
answer**, 403 `relay-target-rejected` with one sentence, because anybody who can write a host can point
it anywhere, and a distinct "resolves, but refused" would be a scanner for the operator's DNS. The real
reason is logged (event 2301).
- `GET /api/v1/relay/connect` is anonymous at the HTTP layer and takes the ticket as the second
subprotocol. It verifies, checks expiry against `TimeProvider`, and spends the ticket with a unique
insert into `relay_ticket_use` before accepting the upgrade — so a refused ticket is a 401 and no socket.
After the upgrade, a refusal the client could not have avoided is a close code: 4429 for a limit, 4502
for a host the server could not reach, 4408 idle, 4409 lifetime, 1001 at the end of a shutdown's drain.
- `relay_session` is the audit row: opened before the dial, closed after, each from a scope of its own.
**The client:**
- `RelayBridge` (Client.Ssh) is the loopback SOCKS5 listener. It binds `127.0.0.1`, accepts once, answers
one CONNECT without acting on its target, and pipes to the WebSocket. It is handed to the connection as
the new `SshLoopbackProxy.Lifetime`, so the connection disposes it on every way a session ends, and a
failed connect disposes it before throwing.
- `ServerRelay` (Client.Session) asks for the ticket, opens the socket and starts the bridge, and turns
every refusal into a sentence. It is `IVaultServer.Relay`, decided from `/api/v1/meta` the way `Events`
is; a server without the feature supplies `UnavailableServerRelay`.
- `VaultViewModel` opens a relay for a host with the box ticked and **never falls back to dialling it
directly** — the user ticked it because that path does not work, or goes somewhere they do not mean. A
relay that closed with a reason replaces SSH.NET's transport error with that reason, and the
reachability probe is skipped for a relayed host, because it would test the direct path.
### ◆ Two things that are not what ADR 0004 wrote
- **No `System.IO.Pipelines`.** Each direction reads one buffer and does not read again until the write
has completed. That is the same end-to-end backpressure — a slow peer stops the read, which fills the
kernel's window, which throttles the far sender — with a tighter bound: two 32 KiB buffers a session
rather than ~2 MiB.
- **No `?ticket=` fallback, and no device id in the ticket.** The fallback exists for clients that cannot
set a subprotocol, which neither head is, and it would put a bearer credential in the one place — the
URL — that access logs keep. A device is never visible to the server on an ordinary request.
### ◆ A WebSocket operation is never cancelled, on either side
Cancelling a pending receive or send on a .NET WebSocket aborts it, and an aborted socket sends no close
frame. The close frame is the only way the client learns that the server could not reach the host, so both
the server's pump and the client's bridge stop a direction by closing and waiting a bounded time instead.
`AnIdleSession_IsClosedWithTheIdleCode` holds the server to it: it asserts the code arrives, not merely that
the socket ended.
### What a developer meets
The relay refuses loopback with no configuration escape, so the development stack's sshd on
`localhost:2222` cannot be reached through it. Point a relayed host at an address the API process reaches
on a real interface — the machine's own LAN address works — or at the container's address on the Docker
network.
## Traps already known
**A relay host may not inherit its port, and a jump host has no such rule.** `TryValidate` enforces the
first because the server stores the port and a group edit would silently change what the relay dials. The
chain has no plaintext counterpart, so it inherits normally — do not copy the restriction across out of
symmetry.
**Two hosts can name each other.** A chain is ids, and nothing stops A jumping through B while B jumps
through A. Resolve iteratively with a visited set and refuse a cycle before dialling anything, rather than
discovering it as a stack overflow inside a connect.
**The bastion's own group defaults matter.** A hop is a host, so it resolves its port, username and binding
through `HostInheritance` exactly as the target does. Skipping that dials 22 as nobody on a bastion that is
on 2222 as `deploy`.
**`ForwardedPortDynamic(0)` reports its port, and the one-argument constructor's bind address is
undocumented.** The first is now pinned by a test rather than trusted. The second is why the two-argument
constructor is always used: if the default were `0.0.0.0` the failure would not be a test failure, it would
be a SOCKS proxy into the developer's network that nothing reports, so the bound host is asserted too.
**◆ The test server refuses forwarding, and says so nowhere useful.** `linuxserver/openssh-server` ships
`AllowTcpForwarding no`. A dynamic forward starts anyway — opening one asks the server nothing — and every
connection through it is then refused at channel-open, which SSH.NET surfaces as `SOCKS5: General failure`,
naming neither the server nor the setting. `SshServerFixture` patches it after start and HUPs sshd. **There
are two `sshd_config` files in that image** and the running server uses `/config/sshd/sshd_config`; patching
`/etc/ssh/sshd_config`, which is the one a search finds first, changes the text and nothing else.
**The relay bridge and the jump bridge are the same class and not the same lifetime.** A ticket is
single-use with a 30-second expiry; a bastion's forward lives as long as the session. Sharing the listener
is right, sharing a lifetime policy is not.
## Tests
- ✅ A connect through a real SOCKS5 forward to a real `sshd`, in `LoopbackProxyTests`. **One container,
used as both ends** — the forward is opened on a connection to the fixture's server and the connection
under test goes back to the same server through it. A second container would look more like the topology
and establish nothing extra: what is under test is that the proxy is honoured, that the target is what
gets pinned, and that a failure on the way through is reported as itself.
- A cycle in a chain is refused before any socket is opened.
- Each hop's host key is asked about separately, and the question names the hop.
- A chain crossing a vault is refused with a reason, as the group picker's is.
- The bridge binds loopback only — assert the bound address, because the failure is silent and the
consequence is an open proxy.
- SFTP to a host behind a chain, once step 4 lands.
- Mutations that must fail something: bind the bridge on `IPAddress.Any`; drop the visited set; skip group
inheritance for a hop; and tear down the outer session without the hops.
## Prose that becomes false
- `docs/design-import-gaps.md` — the host subtitle's `⤷ bastion-eu` row, the SFTP `sftp over bastion-eu`
row, and the status bar's `via bastion-eu` row, all of which say jump hosts are data-only.
- `Client.Import/ImportedHost.cs` — the note written onto every imported host with a `ProxyJump`, and the
remark above it.
- `README.md` and `docs/android-port.md` wherever the relay is described as available. ✅ at step 3.
- ADR 0004 gains a note that its last consequence was built, and how. ✅ at step 3.
## What the first draft of this document got wrong
It recommended deleting `JumpHostIds`, on the evidence that nothing writes it, nothing reads it, and it is
missing from the schema-version ladder. The first two facts are true and the conclusion did not follow.
Two things were missed. **ADR 0004 had already designed the implementation** — the loopback bridge, the
SOCKS5 dynamic forward, "one mechanism, two features" — so the transport was a solved problem sitting in an
accepted ADR, and the fortnight that draft estimated was priced without it. And **the stored shape is
right**: an ordered list of host ids is exactly what a chain is, the merge arm is already correct, and the
missing schema version is a line to add rather than evidence of a bad model.
The lesson is narrower than "read the ADRs": it is that *nothing reads this field* was taken as evidence the
field was a mistake, when it was evidence of an unfinished feature — and the same reasoning applied one
paragraph further would have found the relay checkbox, which is the same shape and is actively lying to
users.
**And then step 1 was wrong too**, in the same direction: it named the bridge as the shared foundation on
the strength of ADR 0004's "one mechanism, two features", without checking which half of the mechanism
SSH.NET already provides. It provides the jump host's half entirely. The shared piece was one level down and
a tenth of the size, and the bridge belongs with the relay. Both corrections came from writing the code
rather than from reading more — which is an argument for the step-at-a-time ordering rather than against
planning, but only if the plan is edited when a step answers back.
+195
View File
@@ -0,0 +1,195 @@
# Unlocking without the passphrase
Every account here is issued a recovery code at enrollment. It is generated on the client, it wraps the
identity bundle, the server stores that wrap, and both heads go to some trouble to make sure the user writes
it down — the phone raises `FLAG_SECURE` for that screen alone and refuses to let anybody click past it.
**Nothing can use it.** There is no code path in this product that opens a recovery wrap. This document is
the plan for the change that fixes that, and it is written to be picked up cold.
> **Status: planned. None of the six steps below is built.**
>
> | Step | State | Notes |
> | --- | --- | --- |
> | 1. The endpoint that serves the wrap | Not started | `GET /api/v1/me/recovery-wrap`, and deliberately not `/me` |
> | 2. `SessionOpener.UnlockWithRecoveryAsync` | Not started | The unwrap, then the path `UnlockAsync` already takes |
> | 3. Setting a new passphrase | Not started | `PUT /api/v1/me/wrap`. Delivers *change passphrase* as well |
> | 4. Both heads | Not started | A way in from the unlock screen, and a box for the code |
> | 5. The prose that becomes false | Not started | Three shipped claims disagree with each other today |
> | 6. `crypto.md`'s status note | Not started | "One of four ways" is one of two, and will be one of three |
## What is wrong
Walk the failure through, because it is worse than a missing feature.
Forget the passphrase and the identity bundle cannot be unwrapped. No bundle means no vault keys, and no
vault keys means every item in every vault is unreadable. Signing out and back in does not help: the server
hands back the same passphrase wrap. The device key would be the other door, and **sign-out withdraws it** —
which is the advice the unlock screen gives for exactly this situation. The recovery wrap sitting on the
server is the only thing left, and nothing opens it.
So the loss is total and permanent, and the thing built to prevent it is inert.
Meanwhile the product says three things about this, and they do not agree with one another:
| What it says | Where | True today |
| --- | --- | --- |
| "The code is the only thing standing between a forgotten passphrase and an unrecoverable vault" | `docs/manual-checks.md` §10.2 | **No.** It stands between nothing |
| "losing it *along with* the passphrase means the vault is unrecoverable" | `docs/android-port.md`, state 4 | **No.** It implies the code alone is enough |
| "Signing out … is the only answer to a forgotten passphrase — nothing can recover one" | `README.md`, Locking | Yes, and it contradicts both of the above |
The third is the honest one. That is the state this plan changes, and until it does, the first two are the
two sentences in this repository most likely to cost somebody their keychain.
## What already exists
More than half of it, which is why this is worth doing now rather than treating as a feature.
- **The code.** `ClientEnrollment.GenerateRecoveryCode` — 160 bits, base32 over a 32-character alphabet with
`I`, `L`, `O` and `U` left out so a transcription cannot land on a different valid code, printed as
32 characters in groups of five.
- **The wrap.** The same class derives `KEK_rc` under `Argon2Profile.RandomSecret` (64 MiB, 3 passes) and
sends `RecoveryWrappedPrivateKey` and `RecoveryKdfParameters` with the enrollment.
- **The row.** `EnrollmentService` stores it as `UserKeyWrapKind.Recovery`, beside the passphrase and device
wraps.
- **The specification.** `docs/crypto.md` §2 gives the parameters for `KEK_rc` and §3 puts the wrap in the
key hierarchy beside the passphrase and device ones. Nothing below needs a spec change.
- **The cache.** `LocalCacheKey` derives from the identity bundle under `dsh1/localcache/v2`, not from `MK`
— see the changed-2026-07-30 note in `crypto.md` §3.2. That change was made partly *for* this: a recovery
unlock derives a different `MK` and would otherwise open the identity and then fail to read the cache it
had itself written. It is already paid for and currently untested from this direction.
## What is missing, exactly
| | Gap | Where it lands |
| --- | --- | --- |
| A | Nothing serves the wrap. `MeResponse` carries `WrappedPrivateKey` and `KdfParameters` — the passphrase pair, and only that | `DodoSSH.Contracts`, `Api/Features/Identity` |
| B | No unlock path. `SessionOpener` has `UnlockAsync` and `UnlockWithDeviceAsync`, and nothing else | `Client.Session` |
| C | No way to set a new passphrase afterwards. No endpoint, no client path, no UI | server + client |
| D | No way in from either unlock screen | `UnlockCard.axaml`, Android `LockedScreen.axaml` |
The device wrap is not a precedent for A: it is cached locally in `StoredUnlockMaterial` at the moment it is
registered, so it never has to be fetched.
## The decisions, and the reasons
**A separate endpoint, not `/me`.** `GET /api/v1/me/recovery-wrap`, called only when somebody has said they
have forgotten their passphrase. `/me` is fetched by every client at every launch, and putting the recovery
wrap in it would hand that blob to anybody holding a stolen OIDC session, permanently, for no benefit. The
wrap's real defence is Argon2id over 160 bits and it does not stop being safe in the response — this is the
cheaper rule of not serving what nothing needs. It answers 404 for an account with no recovery row, which is
every account enrolled by a client that did not send one.
**Online only, and the screen says so.** The wrap is deliberately *not* added to `StoredUnlockMaterial`.
Caching it would put a second door on every laptop, protected by a weaker KDF profile than the passphrase
one, for a case that already requires a network — recovery begins with signing in.
**Setting a new passphrase is part of the flow, not a follow-up.** Without it the account unlocks with a
one-time code forever, and the code is a thing people keep on paper. This is the same re-wrap a *change
passphrase* feature needs, so step 3 delivers both; change-passphrase is then a button, not a project.
**The identity key is not rotated.** Rotating it would invalidate every vault key grant the account holds
and force a rekey of every vault it can read — see [ADR 0010](adr/0010-vault-key-rotation.md) for what one
of those costs, and it is per vault. Nothing about the bundle is compromised by its owner proving possession
of it, so there is nothing to rotate away from. Identity key rotation is M5 and stays there.
**The recovery code is not re-issued after use.** Issuing a new one means asking somebody to write down a
new code at the exact moment they have demonstrated they lose things, and the old code is not weakened by
having been typed. "Issue a new recovery code" is a separate feature with its own screen, and it is the
right place for that question.
**Rate limiting is wanted here and is not a blocker.** This is the first endpoint in the product with a
guessable secret behind it, and `docs/platform-flags.md` records that rate limiting is unimplemented (M2).
Argon2id at 64 MiB is the cost that matters — a guessing attack pays it per attempt — so this ships without
and the endpoint is the reason to do the M2 item next.
## The work, in order
Each step compiles with the whole suite green before the next begins.
1. **The endpoint.** `GET /api/v1/me/recovery-wrap`, returning the wrap and its `KdfParameters`, or 404. A
contract type, an entry in `PublicAPI.Unshipped.txt`, and a `SyncEndpointTests`-style refusal test for an
account without one.
2. **The unlock.** `SessionOpener.UnlockWithRecoveryAsync(string code)`: canonicalise the code, derive
`KEK_rc` from the served parameters, unwrap the bundle, and then join the path `UnlockAsync` already
takes from the moment it holds one. Nothing after the unwrap should be new code — if it is, the two
unlocks have diverged and one of them is wrong.
3. **The re-wrap.** `PUT /api/v1/me/wrap` taking a new passphrase wrap and its parameters, and the client
half that derives the new `KEK_pp` and calls it. Forced as the last step of a recovery: the session is
open, so refusing to go further until a passphrase is set costs nothing and is the only moment the user
is certainly present.
4. **Both heads.** A "forgotten your passphrase?" route from `UnlockCard` and from Android's
`LockedScreen`, a box that accepts the code as printed, and the new-passphrase form after it. The phone's
version has to survive the keyboard covering the box — `docs/manual-checks.md` §10.3 lists the five boxes
that already do, and this is a sixth.
5. **The prose.** Below.
6. **`crypto.md`.** A status note, not a spec change — see the last section.
## Traps already known
**◆ The code is derived from the string exactly as displayed, dashes included.** `GenerateRecoveryCode`
says so where it builds the groups: the separators are not decoration to be stripped before hashing, they
are part of the input. So step 2 must *canonicalise to the printed form* rather than normalise it away —
up-case, then re-group in fives — and it must not simply strip dashes and hash what is left. Getting this
wrong produces a code that verifies nowhere and an error message that says the code is wrong.
**The alphabet has holes in it.** `I`, `L`, `O` and `U` are not in it. A user who writes `O` for `0` should
be met with a code that works, so the canonicaliser should fold the four missing letters onto their
look-alikes before deriving. That is a deliberate leniency and belongs beside the alphabet's own comment.
**The two KDF profiles are not the same, and the stored parameters are what to use.** The passphrase wrap is
`PassphraseDefault` (256 MiB, 4 passes) and the recovery wrap is `RandomSecret` (64 MiB, 3), which is
correct — 160 random bits do not need the same stretching as a chosen phrase. Derive from the parameters the
server served with the wrap, never from a profile constant, or an account enrolled by a client with
different settings will refuse a valid code.
**The cache property is untested from this direction.** A recovery unlock derives a different `MK` and must
still read a cache written under a passphrase unlock. That works today because `LocalCacheKey` hangs off the
bundle, and no test asserts it, because nothing could reach the state. Assert it in step 2 rather than
trusting the note in `crypto.md`.
**Sign-out is the neighbouring path and must stay reachable.** The unlock screen offers signing out as the
answer to a forgotten passphrase. It stays: recovery needs the code, and somebody without it still needs a
way to hand a laptop on. What changes is that it stops being the *only* answer, so the copy on both screens
has to place them beside each other rather than replacing one with the other.
## Tests
- Round trip: enrol, recover, unlock, set a new passphrase, unlock again with it.
- The old passphrase stops working after step 3, and the recovery code still does.
- A wrong code is refused without disclosing whether the account has a recovery wrap at all.
- The cache written under a passphrase unlock is readable after a recovery unlock — the `localcache/v2`
property, asserted rather than assumed.
- An account with no recovery row: the endpoint 404s and the screen says the code was never issued rather
than that it is wrong.
- One `SystemTests` pass against the real API, because this is a two-endpoint flow and that suite is the one
that consumes the artefacts that ship.
- Mutations to confirm each test can fail: reverse the canonicalisation so dashes are stripped; derive from
`Argon2Profile.RandomSecret` instead of from the served parameters; and skip the forced re-wrap in step 3.
## Prose that becomes false
Not a tidy-up. Each of these currently tells a user something that this change makes wrong in the other
direction, and two of them are wrong right now.
- `docs/manual-checks.md` §10.2 — "the only thing standing between a forgotten passphrase and an
unrecoverable vault". True once this ships, false today. It also needs a new check: typing a code from
paper, which is the one part of this no test can perform.
- `docs/android-port.md`, state 4 — "losing it along with the passphrase means the vault is unrecoverable".
- `README.md`, Locking — "Signing out … is the only answer to a forgotten passphrase — nothing can recover
one." Correct today and the sentence this change exists to falsify.
- `docs/design-import-gaps.md`, Preferences — "a forgotten passphrase — which is unrecoverable by
construction, so the unlock screen would otherwise be a dead end", which is the stated justification for
signing out being on that screen. The justification survives; the parenthesis does not.
- Both unlock screens, wherever they name signing out as the answer.
## The spec's "four ways"
`docs/crypto.md` §3.2 says a passphrase is "only one of four ways to open a vault" — passphrase, device,
recovery and escrow. Two exist. This change makes it three; escrow is M5 by decision and has an enum member
and nothing else.
**Do not edit that sentence.** It is normative and it is about the DSH1 format, where four ways is exactly
right. What is missing is a statement about *this build*, so add a short table under §3.2 saying which wrap
kinds this client can create and which it can open, and update it here rather than in the frozen prose. Land
it in the same commit as step 2, so the two can never disagree again.
+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"
+52 -2
View File
@@ -169,16 +169,66 @@ Bump ApplicationVersion, commit, move the tag, and run this again.
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
New-Item -ItemType Directory -Path $Staging | Out-Null
# ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE.
#
# They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is
# not. A process's command line is not a secret on Windows: any process running as this user can read
# every argument of every other with one call —
#
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
#
# — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The
# comment above says the passwords are never stored, and with the arguments written that way it was
# true of the disk and false of everything watching the machine.
#
# env: is .NET for Android's own answer to exactly this, and it names the variable rather than carrying
# the value: MSBuild is handed the string "env:DODOSSH_ANDROID_STOREPASS" and reads the password out of
# the environment itself. The one restriction is $(AndroidPackageFormat) = aab, where env: is not
# supported and file: is the documented alternative; this channel ships an APK, and the guard below
# fails loudly rather than silently signing with the literal string if that ever changes.
#
# Not a perfect channel — an environment block is readable by a process with rights over this one, the
# same as memory is — but it is off the command line, out of the audit trail and out of any log that
# echoes the build invocation, which is the whole of the difference being bought here.
#
# -nodeReuse:false so no MSBuild worker outlives this call holding the variables in its environment.
# The finally clears them from this process either way, and a persistent node would have kept its own
# inherited copy for the next quarter of an hour regardless of what happens here.
$packageFormat = (& dotnet msbuild $Project -getProperty:AndroidPackageFormat -nologo `
-p:DodoChannel=release | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($packageFormat -eq 'aab') {
Stop-With @'
This channel now builds an aab, and the env: prefix this script signs with is not supported for that
format. The signing would silently take "env:DODOSSH_ANDROID_STOREPASS" for the password itself.
Switch both passwords to the file: prefix, which is supported for aab, and make sure whatever writes
that file removes it again.
'@
}
try {
$env:DODOSSH_ANDROID_STOREPASS = $storePlain
$env:DODOSSH_ANDROID_KEYPASS = $keyPlain
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
& dotnet build $Project --no-restore --configuration Release `
-nodeReuse:false `
-t:SignAndroidPackage `
-p:DodoChannel=release `
-p:AndroidKeyStore=true `
-p:AndroidSigningKeyStore=$KeystorePath `
-p:AndroidSigningKeyAlias=$KeyAlias `
-p:AndroidSigningStorePass=$storePlain `
-p:AndroidSigningKeyPass=$keyPlain
-p:AndroidSigningStorePass=env:DODOSSH_ANDROID_STOREPASS `
-p:AndroidSigningKeyPass=env:DODOSSH_ANDROID_KEYPASS
}
finally {
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
# and this runs on the failure path, where it may well not be.
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_STOREPASS', $null)
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_KEYPASS', $null)
}
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
+422
View File
@@ -0,0 +1,422 @@
#!/usr/bin/env bash
#
# Builds, packages and publishes the macOS desktop client.
#
# The counterpart of scripts/release-windows.ps1, and deliberately the same shape: run by a person, on a
# Mac that is not a CI runner, in two phases with the upload withheld until somebody has installed what
# phase one built and walked the manual checks. docs/adr/0011-android-distribution.md rule 1 puts the
# capability to ship somebody a build on a machine which is not a runner, and
# docs/adr/0013-desktop-distribution-and-updates.md explains why the token that writes a Gitea release is
# that capability: Velopack clients trust their feed and do not verify a package signature when they apply
# it, so whoever can write a release can ship an update every install runs.
#
# 1. Without --upload: builds, signs, notarizes, packs, and stops. Nothing has left this machine
# except the notarization submission, which Apple sees and users do not.
# 2. With --upload: asks for the forge token and publishes what phase one produced. It does not
# rebuild, so the bytes that reach users are the bytes that were installed and checked.
#
# ◆ WHAT IS DIFFERENT FROM THE WINDOWS SCRIPT, AND WHY.
#
# Signing is not optional here. On Windows an unsigned installer costs a SmartScreen dialog once per
# user, which is why that script has no --signParams and says so. On macOS an un-notarized download is
# refused outright by Gatekeeper — not warned about, refused — so the Developer ID certificate and the
# notarization round trip are the price of the package being installable at all, not an improvement to
# be bought later.
#
# ◆ CREDENTIALS COME FROM THE KEYCHAIN AND THE ENVIRONMENT, NOT FROM THIS FILE.
#
# Three values are read from the environment, and none of them is itself a secret — they name things the
# keychain holds, and the keychain is what guards the private key and the App Store Connect credentials:
#
# DODOSSH_SIGN_APP_IDENTITY e.g. "Developer ID Application: DodoTech (TEAMID)"
# DODOSSH_SIGN_INSTALL_IDENTITY e.g. "Developer ID Installer: DodoTech (TEAMID)"
# DODOSSH_NOTARY_PROFILE the profile name given to `xcrun notarytool store-credentials`
#
# `security find-identity -v -p codesigning` lists the first two exactly as codesign wants them. The
# third is created once per machine:
#
# xcrun notarytool store-credentials DodoSSH \
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
#
# The forge token is the one real secret, and it is prompted for rather than read from a file or an
# ambient environment variable, and only in the phase that needs it — for the reason the Windows script
# gives: the fewer minutes a credential that can publish an update spends in a shell's memory the better.
# It reaches vpk through VPK_TOKEN, exported for that one call and unset afterwards, because the
# alternative is --token on a command line that is not private. See the comment at the upload itself.
#
# Usage:
# bash scripts/release-macos.sh
# bash scripts/release-macos.sh --upload
# bash scripts/release-macos.sh --skip-tests
set -euo pipefail
UPLOAD=0
SKIP_TESTS=0
for arg in "$@"; do
case "$arg" in
--upload) UPLOAD=1 ;;
--skip-tests) SKIP_TESTS=1 ;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: bash scripts/release-macos.sh [--upload] [--skip-tests]" >&2
exit 1
;;
esac
done
# ---- The contract with every installed client ---------------------------------------------------------
# Velopack's identity for this application, and it is effectively irreversible for the reasons the Windows
# script states — it is what an installed client matches an update against.
#
# ◆ THE SAME PACK ID AS WINDOWS, AND ON THIS PLATFORM IT IS VISIBLE.
#
# vpk names the bundle after the pack id, so this produces DodoSSH.Desktop.app rather than DodoSSH.app,
# and that is what somebody sees in /Applications. It is kept anyway, because the alternative is worse:
# a pack id of DodoSSH would put Velopack's install and its uninstall on ~/Library/Application Support/
# DodoSSH, which is exactly where ClientPaths keeps the encrypted cache, the outbox of changes not yet
# pushed and the device key. Sharing that directory would mean an uninstall silently taking a user's
# un-synced work with it. The same reasoning, and the same conclusion, as the Windows script.
#
# What a person actually reads is CFBundleDisplayName, which build/macos/Info.plist.template sets to
# DodoSSH. So the bundle keeps the id and the Dock shows the product.
PACK_ID='DodoSSH.Desktop'
PACK_TITLE='DodoSSH'
PACK_AUTHORS='DodoTech'
# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
# The owner is part of it: Gitea left a 301 at the old organisation's path, which a GET follows and an
# upload does not.
REPO_URL='https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
# A contract with VelopackUpdateChannel.MacReleaseChannel. Velopack's macOS default is also "osx", so
# leaving it unsaid on both sides would work — but unsaid here and stated there is how a feed goes quiet
# with no error at all: the client checks, finds nothing, and reports itself up to date forever.
CHANNEL='osx'
# ◆ ARM64 ONLY, AND THAT IS A DECISION RATHER THAN AN OVERSIGHT.
#
# Velopack keys a channel to one architecture, so shipping Intel too means a second channel, a second
# publish, a second set of deltas and a second thing to keep in step with the client's channel picker.
# That is all affordable. What is not currently affordable is testing it: nobody here has an Intel Mac,
# and docs/manual-checks.md exists because this project does not ship desktop builds no one has run.
# An x64 package built blind and published beside a checked arm64 one would be the only artefact in this
# repository that reached users unverified.
#
# Adding it later is this constant, a second channel name in VelopackUpdateChannel, and a picker keyed on
# RuntimeInformation.ProcessArchitecture — which reports X64 for a build running under Rosetta, so an
# Intel build correctly stays on the Intel feed. The work is small; the check is the part that is missing.
RUNTIME='osx-arm64'
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT="$REPO_ROOT/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj"
SOLUTION="$REPO_ROOT/DodoSSH.slnx"
PUBLISH_DIR="$REPO_ROOT/publish/$RUNTIME"
RELEASES_DIR="$REPO_ROOT/Releases"
ICON="$REPO_ROOT/src/DodoSSH.Client.App/Assets/dodossh.icns"
ENTITLEMENTS="$REPO_ROOT/build/macos/DodoSSH.entitlements"
PLIST_TEMPLATE="$REPO_ROOT/build/macos/Info.plist.template"
write_step() { printf '\n\033[36m==> %s\033[0m\n' "$1"; }
stop_with() { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; }
# ---- Is this machine able to do the job at all? -------------------------------------------------------
if [ "$(uname -s)" != 'Darwin' ]; then
# codesign, notarytool and stapler are Apple tooling and exist nowhere else. The build and even the
# .app bundle cross-compile fine from Windows or Linux — `vpk [osx] bundle` does exactly that, and
# ci.yml uses it to prove the bundle still builds — but a signed, notarized, installable package
# cannot be produced anywhere but here.
stop_with 'This builds a signed macOS package and has to run on macOS.'
fi
for tool in dotnet git xcrun codesign; do
command -v "$tool" >/dev/null 2>&1 || stop_with "$tool is not on PATH."
done
# Checked before anything is built rather than at the step that uses them. Notarization is the last thing
# this script does and the slowest, and discovering there that a profile name was never exported means
# throwing away a full build and test run.
for required in DODOSSH_SIGN_APP_IDENTITY DODOSSH_SIGN_INSTALL_IDENTITY DODOSSH_NOTARY_PROFILE; do
if [ -z "${!required-}" ]; then
stop_with "$required is not set. See the header of this script for what the three are and how to make them."
fi
done
cd "$REPO_ROOT"
# ---- What is being released ---------------------------------------------------------------------------
# Restored before the version is read, and both halves are load-bearing — the same two traps the Windows
# script documents. -t:MinVer, because -getProperty alone evaluates the project and runs no targets, while
# MinVer sets Version from inside one, so the read would answer the SDK's default 1.0.0 regardless of the
# tag. And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
# clone where obj/ has no MinVer targets to import yet.
write_step 'Restoring the desktop head, so the version can be read'
dotnet restore "$PROJECT" --locked-mode || stop_with 'Restore failed.'
VERSION="$(dotnet msbuild "$PROJECT" -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
[ -n "$VERSION" ] || stop_with 'Could not read the version from MSBuild.'
TAG="v$VERSION"
# Apple's two version keys take one to three dot-separated integers and nothing else, so a prerelease
# version has to have its suffix removed before it reaches the plist. 1.2.3-rc.1 becomes 1.2.3.
#
# The full version, suffix and all, is what vpk packs and what the release index carries, so the updater
# still tells an rc from the release it precedes. These two keys are for Finder and Gatekeeper, which
# care that the string parses and not what it says. See build/macos/Info.plist.template.
PLIST_VERSION="${VERSION%%-*}"
PLIST_VERSION="${PLIST_VERSION%%+*}"
write_step "DodoSSH $VERSION ($PACK_ID, channel $CHANNEL, $RUNTIME)"
# ---- Phase 2: publish what phase 1 built --------------------------------------------------------------
if [ "$UPLOAD" -eq 1 ]; then
# The installer package is the artefact a person downloads, so its absence is the honest test of
# whether phase one ever ran. A directory holding only a .nupkg is a pack that failed part way.
if ! ls "$RELEASES_DIR"/*.pkg >/dev/null 2>&1; then
stop_with "Nothing to upload: $RELEASES_DIR has no .pkg. Run this without --upload first."
fi
echo "About to publish the contents of $RELEASES_DIR to $REPO_URL as $TAG."
echo 'Only do this once you have installed it and walked Phase 18 of docs/manual-checks.md.'
# -s so the token is never echoed and never lands in the shell's history.
printf 'Gitea token (write:repository): '
read -r -s TOKEN
echo
[ -n "$TOKEN" ] || stop_with 'No token given.'
# --merge because Gitea already has a release entry for the pushed tag — and on this platform it may
# also already hold the Windows package for the same tag, which is the case --merge is really doing
# the work for: without it the second platform to publish a given version fails on a release that
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
#
# No --token in this array, and that is deliberate. It used to be there, and an argument is not a
# private place to put a credential: /proc/<pid>/cmdline is world-readable on Linux, `ps` hands out
# other processes' arguments freely, and whatever agent is auditing process creation on this machine
# keeps its own copy. The comment above is about shell history and was never the whole story.
#
# vpk documents VPK_TOKEN as --token's environment fallback, so the value never becomes an argument.
# Exported for the one call and unset straight after; an environment block is still readable by a
# process with rights over this one, but it is out of every process listing and audit log.
UPLOAD_ARGS=(
upload gitea
--repoUrl "$REPO_URL"
--outputDir "$RELEASES_DIR"
--channel "$CHANNEL"
--releaseName "$TAG"
--tag "$TAG"
--merge
--publish
)
# Mirrors the rule the docker image job and the Windows script already apply to the same tag, so a
# release candidate is a prerelease in every channel or in none.
case "$VERSION" in
*-*) UPLOAD_ARGS+=(--pre) ;;
esac
write_step 'Uploading'
# Unset on the way out whether or not the upload worked, so a failed release does not leave the token
# sitting in this shell for whatever runs next.
export VPK_TOKEN="$TOKEN"
vpk_status=0
dotnet vpk "${UPLOAD_ARGS[@]}" || vpk_status=$?
unset VPK_TOKEN
[ "$vpk_status" -eq 0 ] || stop_with 'vpk upload failed.'
write_step "Published $TAG."
exit 0
fi
# ---- Phase 1: build, sign, notarize, pack -------------------------------------------------------------
[ -z "$(git status --porcelain)" ] || stop_with 'The working tree is not clean. A release is cut from a commit, not from a desk.'
HEAD_TAG="$(git describe --exact-match --tags HEAD 2>/dev/null || true)"
[ -n "$HEAD_TAG" ] || stop_with "HEAD is not tagged. Tag it $TAG first, or change the version and tag that."
# Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the day
# somebody pins a version by hand this is the guard that notices.
[ "$HEAD_TAG" = "$TAG" ] || stop_with "HEAD is tagged $HEAD_TAG but the computed version is $VERSION."
write_step 'Restoring tools'
dotnet tool restore || stop_with 'dotnet tool restore failed.'
write_step 'Restoring packages (locked, exactly as CI does)'
dotnet restore "$SOLUTION" --locked-mode || stop_with 'Restore failed. A lock file that only works on Linux fails here.'
write_step 'Building'
dotnet build "$SOLUTION" --no-restore --configuration Release || stop_with 'Build failed.'
if [ "$SKIP_TESTS" -eq 0 ]; then
# The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
# on trust from CI, because a tag is the one build nobody is watching — and on this platform there is
# a second reason: CI has no macOS runner, so this is the only place the suite ever runs on a Mac at
# all. Everything docs/platform-flags.md lists as unverified on macOS is verified here or nowhere.
write_step 'Testing'
dotnet test "$SOLUTION" --no-build --configuration Release || stop_with 'Tests failed.'
fi
write_step "Publishing $RUNTIME"
rm -rf "$PUBLISH_DIR"
# Self-contained, and not single-file, for the reasons the Windows script gives: the native libraries ship
# per RID and a self-extracting bundle breaks delta updates.
#
# RestoreLockedMode=false, and the lock files put back straight afterwards. A RID-specific publish resolves
# a graph the committed lock files do not describe, because they are deliberately kept RID-free —
# declaring a RID on the head writes a net10.0/<rid> target into every project it references transitively,
# including DodoSSH.Contracts and DodoSSH.Crypto, and the API's Dockerfile then restores those with no RID
# under locked mode and fails NU1004. Packaging the desktop client would have broken the server's image
# build. The gate that matters is the locked solution restore above, which is untouched.
dotnet publish "$PROJECT" \
--configuration Release \
--runtime "$RUNTIME" \
--self-contained true \
--output "$PUBLISH_DIR" \
-p:RestoreLockedMode=false \
|| stop_with 'Publish failed.'
# An unlocked restore rewrites the lock files it walked. Left there, the next commit would carry exactly
# the change that breaks the image build. Safe to do bluntly because this script refuses to run on a dirty
# tree, so anything modified here is its own.
git checkout -- '*packages.lock.json' || stop_with 'Could not restore the lock files after publishing.'
# Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for an
# application that never checks for updates — which looks completely normal until the next release goes out
# and nobody receives it.
for required in DodoSSH Velopack.dll; do
[ -e "$PUBLISH_DIR/$required" ] || stop_with "$required is missing from $PUBLISH_DIR."
done
echo " $(du -sh "$PUBLISH_DIR" | cut -f1) in $(find "$PUBLISH_DIR" -type f | wc -l | tr -d ' ') files"
# ---- Signing the native libraries, before vpk signs anything ------------------------------------------
# ◆ THIS LOOP IS WHY NOTARIZATION SUCCEEDS, AND IT LOOKS REDUNDANT.
#
# vpk signs the finished bundle itself, with `codesign -f -v --timestamp --options runtime --entitlements
# <file> --deep`, and --deep is documented by Apple as the wrong way to sign nested code. Apple's guidance
# is inside-out: sign each nested binary first, then the bundle around it. --deep does the reverse in one
# pass and applies the outer entitlements to everything it touches.
#
# In practice --deep alone is where the failure recorded in docs/platform-flags.md comes from — a
# notarization rejection that does not name the offending file, on a submission that took its time getting
# there. Signing each dylib properly first means vpk's pass has nothing left to get wrong, and re-signing
# an already correctly signed binary with -f is a no-op in effect.
#
# No --entitlements here, and that is the difference that matters. Entitlements belong on the main
# executable; a dylib carrying allow-jit is at best meaningless and at worst a rejection.
write_step 'Signing native libraries'
# createdump is a Mach-O executable the runtime ships and it is signed like the libraries: a nested
# executable that is not signed fails notarization exactly as an unsigned dylib does, and it is the one
# people forget because it has no extension to grep for.
NATIVE_COUNT=0
while IFS= read -r -d '' binary; do
codesign --force --verbose=0 --timestamp --options runtime \
--sign "$DODOSSH_SIGN_APP_IDENTITY" "$binary" \
|| stop_with "codesign failed on $binary"
NATIVE_COUNT=$((NATIVE_COUNT + 1))
done < <(find "$PUBLISH_DIR" \( -name '*.dylib' -o -name 'createdump' \) -type f -print0)
[ "$NATIVE_COUNT" -gt 0 ] || stop_with "No native binaries found under $PUBLISH_DIR, which cannot be right for a self-contained publish."
echo " signed $NATIVE_COUNT native binaries"
# ---- The bundle's Info.plist --------------------------------------------------------------------------
# Rendered rather than committed, because vpk copies a custom plist verbatim and substitutes nothing —
# so a committed one would carry whatever version it was written with into every release afterwards.
# See the header of build/macos/Info.plist.template.
write_step "Rendering Info.plist for $PLIST_VERSION"
RENDERED_PLIST="$(mktemp -t dodossh-plist)"
trap 'rm -f "$RENDERED_PLIST"' EXIT
sed "s/@VERSION@/$PLIST_VERSION/g" "$PLIST_TEMPLATE" > "$RENDERED_PLIST"
# The placeholder is the whole mechanism, so its absence is checked rather than hoped for. A template
# somebody edited into a literal version would otherwise sail through and pin every future release to it.
grep -q '@VERSION@' "$PLIST_TEMPLATE" || stop_with "$PLIST_TEMPLATE has no @VERSION@ placeholder left in it."
! grep -q '@VERSION@' "$RENDERED_PLIST" || stop_with 'Substitution into the rendered Info.plist did not take.'
mkdir -p "$RELEASES_DIR"
# The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
# macOS release has no predecessor, and a hard failure here would make cutting it impossible.
write_step 'Fetching the previous release, for deltas'
if ! dotnet vpk download gitea --repoUrl "$REPO_URL" --outputDir "$RELEASES_DIR" --channel "$CHANNEL"; then
echo ' Nothing came down. This package will be full-only, which is right for a first release.'
fi
# ---- Pack, sign, notarize, staple ---------------------------------------------------------------------
# One command does the rest, and it is worth knowing what it is doing on your behalf, because the slow
# part is not local: it builds the .app from the published files, signs it with the Developer ID
# certificate and the entitlements below, submits it to Apple with `xcrun notarytool submit --wait`,
# staples the resulting ticket to the package, and then builds the .pkg installer and the release index.
#
# The notarization wait is the reason this step can take a quarter of an hour and occasionally much
# longer — it is a queue at Apple, not a computation here, and vpk's own message says so.
#
# --signInstallIdentity is a different certificate from --signAppIdentity, and the pair is not
# interchangeable: "Developer ID Application" signs the bundle, "Developer ID Installer" signs the .pkg.
# Passing one where the other belongs fails with a message about an identity that cannot be found, which
# reads like a keychain problem rather than like the wrong certificate.
write_step 'Packing, signing and notarizing (the notarization wait is Apple queueing, not this machine)'
dotnet vpk pack \
--packId "$PACK_ID" \
--packVersion "$VERSION" \
--packDir "$PUBLISH_DIR" \
--packTitle "$PACK_TITLE" \
--packAuthors "$PACK_AUTHORS" \
--mainExe 'DodoSSH' \
--icon "$ICON" \
--plist "$RENDERED_PLIST" \
--entitlements "$ENTITLEMENTS" \
--signAppIdentity "$DODOSSH_SIGN_APP_IDENTITY" \
--signInstallIdentity "$DODOSSH_SIGN_INSTALL_IDENTITY" \
--notaryProfile "$DODOSSH_NOTARY_PROFILE" \
--runtime "$RUNTIME" \
--channel "$CHANNEL" \
--outputDir "$RELEASES_DIR" \
|| stop_with 'vpk pack failed.'
# ---- Did the notarization actually take? --------------------------------------------------------------
# Asked rather than assumed, and this is the check worth having above all the others. A package whose
# ticket did not staple is indistinguishable from a good one on the machine that built it — the Mac that
# signed something trusts it locally — and reveals itself only on somebody else's machine, as a refusal
# to open at all. spctl assesses it the way Gatekeeper will on a machine that has never seen this
# certificate.
write_step 'Verifying the notarization the way another Mac will'
PKG="$(ls -t "$RELEASES_DIR"/*.pkg 2>/dev/null | head -n 1)"
[ -n "$PKG" ] || stop_with 'vpk pack reported success but produced no .pkg.'
if ! spctl --assess --type install --verbose=4 "$PKG"; then
stop_with "Gatekeeper rejects $PKG. It is signed but the notarization ticket is missing or stale; do not upload it."
fi
xcrun stapler validate "$PKG" || stop_with "The notarization ticket is not stapled to $PKG."
write_step 'Built, notarized, and deliberately not uploaded'
ls -lh "$RELEASES_DIR" | tail -n +2
cat <<EOF
Next:
1. Install the .pkg above and walk Phase 18 of docs/manual-checks.md.
2. Then: bash scripts/release-macos.sh --upload
EOF
+50 -5
View File
@@ -16,9 +16,11 @@
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
so the bytes that reach users are the bytes that were installed and checked.
The token is prompted for rather than read from a file or an environment variable, and only in the phase
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
in a shell's memory the better.
The token is prompted for rather than read from a file or an ambient environment variable, and only in
the phase that needs it — the build does not, and the fewer minutes a credential that can publish an
update spends in a shell's memory the better. It reaches vpk through VPK_TOKEN, set for that one call
and cleared afterwards, because the alternative is --token on a command line every process on this
machine can read. See the comment at the upload itself.
.PARAMETER Upload
Publish the packages already in Releases/ instead of building.
@@ -71,6 +73,11 @@ $RepoUrl = 'https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it
# unsaid on both sides would work too — but unsaid here and stated there is how a feed goes quiet with no
# error at all: the client checks, finds nothing, and reports itself up to date forever.
#
# The nightly channel is the same three values with different contents — DodoSSH.Desktop.Nightly, DodoSSH
# Nightly, win-nightly — and they live in the `desktop nightly` job in .github/workflows/ci.yml rather than
# here, because that build is CI's and this script is a person's. Nothing shares them, deliberately: this
# file must keep working if that job is deleted. See ADR 0013 decision 9 for what the separation buys.
$Channel = 'win'
$RepoRoot = Split-Path -Parent $PSScriptRoot
@@ -100,7 +107,21 @@ Push-Location $RepoRoot
try {
# ---- What is being released -----------------------------------------------------------------------
$version = (& dotnet msbuild $Project -getProperty:Version -nologo) -replace '\s', ''
# Restored before the version is read, and both halves of that sentence are load-bearing.
#
# -t:MinVer, because -getProperty on its own evaluates the project and runs no targets, while MinVer
# sets Version from inside one — so this read answered the SDK's default 1.0.0 regardless of the tag,
# and the check below would then have refused to build anything not tagged v1.0.0. CI's tag check had
# the same line and the same fault.
#
# And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
# clone, where obj/ has no MinVer targets to import yet. It costs seconds on a machine that has built
# before, which is every machine except the one this would otherwise fail on.
Write-Step 'Restoring the desktop head, so the version can be read'
& dotnet restore $Project --locked-mode
if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed.' }
$version = (& dotnet msbuild $Project -getProperty:Version -t:MinVer -nologo) -replace '\s', ''
if ([string]::IsNullOrWhiteSpace($version)) {
Stop-With 'Could not read the version from MSBuild.'
}
@@ -133,10 +154,24 @@ try {
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
# fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
# same tag, so a release candidate is a prerelease in both channels or in neither.
#
# ◆ NO --token HERE. IT WAS AN ARGUMENT, AND AN ARGUMENT IS PUBLIC ON THIS MACHINE.
#
# The comment above says the token never lands in shell history, which was true and was not the
# whole story: it went straight into vpk's command line, and any process running as this user can
# read that off a running process with one call —
#
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
#
# — with process-creation auditing and EDR agents keeping their own copy afterwards. A token that
# can publish an update every installed copy will fetch and run deserves better than that.
#
# vpk documents VPK_TOKEN as --token's environment fallback, so the flag simply goes away and the
# value never becomes an argument at all. An environment block is still readable by a process with
# rights over this one; what it is not is a string sitting in a process listing and an audit log.
$uploadArgs = @(
'upload', 'gitea',
'--repoUrl', $RepoUrl,
'--token', $token,
'--outputDir', $ReleasesDir,
'--channel', $Channel,
'--releaseName', $tag,
@@ -150,7 +185,17 @@ try {
}
Write-Step 'Uploading'
try {
$env:VPK_TOKEN = $token
& dotnet vpk @uploadArgs
}
finally {
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
# and this runs on the failure path, where it may well not be.
[Environment]::SetEnvironmentVariable('VPK_TOKEN', $null)
}
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
Write-Step "Published $tag."
+13 -4
View File
@@ -2,9 +2,18 @@
#
# docker build -f src/DodoSSH.Api/Dockerfile .
#
# Directory.Build.props, Directory.Packages.props, NuGet.config and global.json all sit at
# the root and all four are load-bearing here — central package management means a csproj
# alone does not name a single version, and a build that cannot see them resolves nothing.
# Directory.Build.props, Directory.Build.targets, Directory.Packages.props, NuGet.config and
# global.json all sit at the root and all five are load-bearing here — central package
# management means a csproj alone does not name a single version, and a build that cannot see
# them resolves nothing.
#
# Directory.Build.targets is the one that is easy to forget, because leaving it out breaks
# nothing until the day it does: it pins the version of the trimmer the SDK adds to
# DodoSSH.Contracts and DodoSSH.Crypto by itself (see the comment there). Without it the restore
# below asks for whatever ILLink the image's SDK bundles, which matches the lock files only
# until .NET ships a servicing release — and then this image fails with NU1004 while ci.yml,
# which does see the pin, stays green. That is how it was found: sdk:10.0-noble moved to
# 10.0.401 (ILLink 10.0.12) and this build broke with no commit touching a package.
# ---------------------------------------------------------------------------------------
# Build
@@ -26,7 +35,7 @@ WORKDIR /src
# dependency change and by nothing else. Every project in DodoSSH.Api's reference closure
# has to be here — restore walks ProjectReference, and a missing csproj fails the graph
# rather than skipping a node.
COPY global.json NuGet.config Directory.Build.props Directory.Packages.props ./
COPY global.json NuGet.config Directory.Build.props Directory.Build.targets Directory.Packages.props ./
COPY src/DodoSSH.Api/DodoSSH.Api.csproj src/DodoSSH.Api/
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/
@@ -211,10 +211,23 @@ internal sealed class VaultEventsEndpoint(
/// <summary>Runs the socket until something ends it, then closes it politely.</summary>
/// <remarks>
/// <para>
/// Three loops rather than one: reading a socket and writing to it are independent waits, and the
/// clock is a third. They are joined by <see cref="Task.WhenAny(Task[])"/> and then <em>all</em>
/// awaited before the close is written, because a close frame racing a notice frame is a protocol
/// clock is a third. They are joined by <see cref="Task.WhenAny(Task[])"/>, and the sender is
/// finished before the close is written, because a close frame racing a notice frame is a protocol
/// violation that presents as a client dropping its connection for no visible reason.
/// </para>
/// <para>
/// <b>No operation on the WebSocket is ever cancelled.</b> Cancelling a pending receive or send on
/// the socket Kestrel hands over aborts it, and an aborted socket cannot send the close frame that
/// is the only way a client learns <em>why</em> it was dropped — 4401 "reconnect with a fresh
/// token" against 1001 "the server is going away" against a failure to back off from. This was
/// once the case here, and it went unnoticed because <c>TestServer</c>'s in-memory socket survives
/// the cancellation that a real one does not. So the token stops the queue and the clock; the
/// sender is let finish its last write; the close goes out; and the receiver ends on the client's
/// answering close. Each wait is bounded, and the socket is aborted only when a peer has stopped
/// answering altogether. The relay's pump does the same, for the same reason.
/// </para>
/// </remarks>
private async Task PumpAsync(
WebSocket socket,
@@ -236,15 +249,21 @@ internal sealed class VaultEventsEndpoint(
VaultCount: connection.VaultCount));
var sending = SendAsync(socket, connection, pump.Token);
var receiving = ReceiveAsync(socket, connection, pump.Token);
var receiving = ReceiveAsync(socket, connection);
var minding = MindAsync(connection, closure, pump.Token);
await Task.WhenAny(sending, receiving, minding).ConfigureAwait(false);
await pump.CancelAsync().ConfigureAwait(false);
// Nothing may still be mid-send when the close frame goes out.
await Task.WhenAll(Settled(sending), Settled(receiving), Settled(minding)).ConfigureAwait(false);
// Nothing may still be mid-send when the close frame goes out. A send that does not finish is
// a client that has stopped reading, which will not read a close frame either.
if (!await SettlesAsync(sending).ConfigureAwait(false))
{
socket.Abort();
}
await SettlesAsync(minding).ConfigureAwait(false);
if (lifetime.ApplicationStopping.IsCancellationRequested)
{
@@ -256,6 +275,14 @@ internal sealed class VaultEventsEndpoint(
EventsLog.ClosingConnection(logger, connection.UserId, closure.Reason);
await CloseAsync(socket, closure).ConfigureAwait(false);
// The client's answering close is what ends the receiver. Returning before it arrives would
// dispose the socket under a close frame still in flight.
if (!await SettlesAsync(receiving).ConfigureAwait(false))
{
socket.Abort();
await SettlesAsync(receiving).ConfigureAwait(false);
}
}
/// <summary>
@@ -287,8 +314,9 @@ internal sealed class VaultEventsEndpoint(
var bytes = JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent);
// Not cancellable, deliberately. See PumpAsync.
await socket
.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken)
.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None)
.ConfigureAwait(false);
}
}
@@ -297,21 +325,26 @@ internal sealed class VaultEventsEndpoint(
/// Reads what the client sends, which in this version is heartbeats and a close.
/// </summary>
/// <remarks>
/// <para>
/// A client cannot ask to follow a vault, and that is deliberate rather than unfinished: a
/// <c>subscribe(vaultId)</c> frame is an existence oracle for vault ids, which is the disclosure
/// <c>SyncPullEndpoint</c> answers 404 rather than 403 to avoid. Subscription is decided from the
/// caller's access and nothing else.
/// </para>
/// <para>
/// It takes no token: it ends on the client's close, a frame it refuses, or the socket failing —
/// and when the server is the one ending the connection, the close it sends is what prompts the
/// first of those. See <see cref="PumpAsync"/>.
/// </para>
/// </remarks>
private static async Task ReceiveAsync(
WebSocket socket,
VaultEventConnection connection,
CancellationToken cancellationToken)
private static async Task ReceiveAsync(WebSocket socket, VaultEventConnection connection)
{
var buffer = new byte[MaxInboundFrameBytes];
while (!cancellationToken.IsCancellationRequested)
while (true)
{
var received = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
// Not cancellable, deliberately. See PumpAsync.
var received = await socket.ReceiveAsync(buffer, CancellationToken.None).ConfigureAwait(false);
if (received.MessageType == WebSocketMessageType.Close)
{
@@ -492,31 +525,39 @@ internal sealed class VaultEventsEndpoint(
}
/// <summary>
/// Awaits a pump loop, treating its cancellation and its socket faults as the ordinary end.
/// Waits for a pump loop to finish, for at most the close timeout.
/// </summary>
/// <returns>Whether it finished.</returns>
/// <remarks>
/// Every one of these loops ends by being cancelled or by the socket going away, so an exception
/// here is the expected shape of "this connection is over" rather than a fault to propagate — and
/// propagating it would skip the close frame the other side is waiting for.
/// </remarks>
private static async Task Settled(Task loop)
private static async Task<bool> SettlesAsync(Task loop)
{
try
{
await loop.ConfigureAwait(false);
await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
return true;
}
catch (TimeoutException)
{
return false;
}
catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
{
// Expected.
return true;
}
}
/// <summary>Why the socket is being closed, decided by whichever loop ended first.</summary>
/// <remarks>
/// Mutable and shared, and safe without a lock for one specific reason: it is written by the pump
/// loops and read only after <see cref="Task.WhenAll(Task[])"/> over all of them, which is a
/// memory barrier. Writes race only with each other, and any of them is a true answer.
/// Mutable and shared, and safe without a lock for one specific reason: it is written only by
/// <see cref="MindAsync"/>, and read only after that loop has been awaited, which is a memory
/// barrier. If that loop outlived the close timeout, a late write races the read, and either
/// answer is still a true one.
/// </remarks>
private sealed class Closure(int code, string reason)
{
@@ -26,9 +26,6 @@ internal sealed class GetMetaEndpoint(
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
internal const int SyncProtocolVersion = 1;
/// <summary>Feature flag for the relay.</summary>
internal const string RelayFeature = "relay";
/// <inheritdoc />
public override void Configure()
{
@@ -50,7 +47,7 @@ internal sealed class GetMetaEndpoint(
List<string> features = ["teams"];
if (relay.Value.Enabled)
{
features.Add(RelayFeature);
features.Add(RelayProtocol.Feature);
}
// Advertised so a client knows whether to hold a socket open or rely on its timer. Absence is
@@ -0,0 +1,192 @@
using System.Net;
using System.Net.Sockets;
using DodoSSH.Api.Setup;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Decides which addresses and ports the relay may dial. ADR 0004 §3, in the order it is written.
/// </summary>
/// <remarks>
/// <para>
/// This runs on <em>addresses</em>, never on names. The name is resolved once, at ticket issue, and what
/// this permits goes into the ticket; the socket dials those addresses and nothing else. A check on the
/// name would be defeated by DNS rebinding — answer with a public address for the check and a loopback
/// one for the dial — which is the attack ADR 0004 is built around.
/// </para>
/// <para>
/// Three layers, and the order is the security property. The <see cref="Refused"/> list comes first and
/// has no configuration at all: nothing an operator sets, and nothing a host row says, reaches a
/// loopback, link-local, unspecified, multicast or metadata address. Then the operator's own denials,
/// then their allow-list, then the private-network switch.
/// </para>
/// <para>
/// <b>An address that embeds another is judged as the one it embeds.</b> <c>::ffff:127.0.0.1</c> is
/// loopback however it is spelled, and a check that compared it against IPv6 loopback alone would pass
/// it — the classic bypass the ADR names. The NAT64 well-known prefix is unwrapped the same way, because
/// on a network with a NAT64 gateway <c>64:ff9b::a9fe:a9fe</c> <em>is</em> the metadata endpoint.
/// </para>
/// </remarks>
internal sealed class RelayAddressPolicy
{
/// <summary>
/// Addresses the relay never dials, whatever it is configured to do.
/// </summary>
/// <remarks>
/// Broader than the ADR's list in two places, both deliberately. <c>240.0.0.0/4</c> — reserved, and
/// containing the limited broadcast address — has no host worth reaching. And IPv6 <c>::/96</c>, the
/// deprecated IPv4-compatible block, covers the unspecified address and loopback while also refusing
/// the spellings of IPv4 that a stack may or may not map; nothing legitimate lives there.
/// </remarks>
private static readonly IPNetwork[] Refused =
[
// IPv4: "this network", loopback, link-local (which holds 169.254.169.254), multicast, reserved.
IPNetwork.Parse("0.0.0.0/8"),
IPNetwork.Parse("127.0.0.0/8"),
IPNetwork.Parse("169.254.0.0/16"),
IPNetwork.Parse("224.0.0.0/4"),
IPNetwork.Parse("240.0.0.0/4"),
// Alibaba Cloud's metadata endpoint, which sits inside carrier-grade NAT space and so would
// otherwise be permitted as private — the range a Tailnet also uses, which is why the whole
// block cannot simply be refused.
IPNetwork.Parse("100.100.100.200/32"),
// IPv6: IPv4-compatible (with unspecified and loopback), link-local, multicast, and the AWS
// metadata endpoint's IPv6 address.
IPNetwork.Parse("::/96"),
IPNetwork.Parse("fe80::/10"),
IPNetwork.Parse("ff00::/8"),
IPNetwork.Parse("fd00:ec2::254/128"),
];
/// <summary>
/// Ranges <see cref="RelayOptions.AllowPrivateNetworks"/> governs.
/// </summary>
/// <remarks>
/// Carrier-grade NAT is here rather than treated as public, because it is where a Tailnet lives and
/// "the server is on the same Tailnet" is one of the shapes the relay exists for. Site-local
/// <c>fec0::/10</c> is deprecated and was private while it lived.
/// </remarks>
private static readonly IPNetwork[] Private =
[
IPNetwork.Parse("10.0.0.0/8"),
IPNetwork.Parse("172.16.0.0/12"),
IPNetwork.Parse("192.168.0.0/16"),
IPNetwork.Parse("100.64.0.0/10"),
IPNetwork.Parse("fc00::/7"),
IPNetwork.Parse("fec0::/10"),
];
/// <summary>The NAT64 well-known prefix, RFC 6052, whose low 32 bits are an IPv4 address.</summary>
private static readonly IPNetwork Nat64 = IPNetwork.Parse("64:ff9b::/96");
/// <summary>
/// Ports the relay never dials.
/// </summary>
/// <remarks>
/// SMTP and nothing else, per ADR 0004: a relay that reached port 25 is a spam cannon with the
/// operator's address on it. Database ports stay open, because forwarding to one is a headline use,
/// and the control that matters there is who may dial the host at all.
/// </remarks>
private static readonly int[] RefusedPorts = [25, 465, 587];
private readonly bool allowPrivate;
private readonly IPNetwork[] allowed;
private readonly IPNetwork[] denied;
public RelayAddressPolicy(RelayOptions options)
{
ArgumentNullException.ThrowIfNull(options);
allowPrivate = options.AllowPrivateNetworks;
allowed = ParseAll(options.AllowedNetworks);
denied = ParseAll(options.DeniedNetworks);
}
/// <summary>Whether a port may be dialled at all.</summary>
internal static bool PermitsPort(int port) =>
port is >= 1 and <= 65535 && !RefusedPorts.Contains(port);
/// <summary>
/// Whether every entry is a network this policy can read.
/// </summary>
/// <remarks>
/// For startup validation. A typo in a deny list that was silently skipped would be a hole the
/// operator believes is closed, so an unreadable entry stops the server instead.
/// <para>
/// Host bits are refused as well, although <see cref="IPNetwork.TryParse(string?, out IPNetwork)"/>
/// would mask them off quietly. <c>10.20.5.7/16</c> could be a /16 written from memory or a /32 with
/// the wrong suffix, and in a deny list those differ by sixty-five thousand addresses.
/// </para>
/// </remarks>
internal static bool AreNetworks(IEnumerable<string> entries) =>
entries.All(entry =>
IPNetwork.TryParse(entry, out var network)
&& IPAddress.TryParse(entry.AsSpan(0, entry.IndexOf('/', StringComparison.Ordinal)), out var written)
&& written.Equals(network.BaseAddress));
/// <summary>
/// The address the relay would actually be talking to, with any embedding unwrapped.
/// </summary>
internal static IPAddress Normalise(IPAddress address)
{
ArgumentNullException.ThrowIfNull(address);
if (address.IsIPv4MappedToIPv6)
{
return address.MapToIPv4();
}
if (address.AddressFamily == AddressFamily.InterNetworkV6 && Nat64.Contains(address))
{
var bytes = address.GetAddressBytes();
return new IPAddress(bytes.AsSpan(12, 4));
}
// A scope id is how an address names an interface on this machine, which is not something a
// host row gets to choose. Dropped, so what is judged is what is dialled.
return address.AddressFamily == AddressFamily.InterNetworkV6 && address.ScopeId != 0
? new IPAddress(address.GetAddressBytes())
: address;
}
/// <summary>Whether the relay may dial this address.</summary>
/// <remarks>
/// Judges the normalised address. Callers should dial <see cref="Normalise"/>'s answer too, so that
/// the address checked and the address connected to are the same object rather than two spellings
/// the operating system is trusted to agree about.
/// </remarks>
internal bool Permits(IPAddress address)
{
var target = Normalise(address);
if (Refused.Any(network => Within(network, target)))
{
return false;
}
if (denied.Any(network => Within(network, target)))
{
return false;
}
if (allowed.Length > 0)
{
return allowed.Any(network => Within(network, target));
}
return allowPrivate || !Private.Any(network => Within(network, target));
}
/// <remarks>
/// <see cref="IPNetwork.Contains"/> answers false across address families rather than throwing, so
/// a list mixing IPv4 and IPv6 needs no sorting — but it is spelled out here because the one-line
/// alternative reads as though it compared across families.
/// </remarks>
private static bool Within(IPNetwork network, IPAddress address) =>
network.BaseAddress.AddressFamily == address.AddressFamily && network.Contains(address);
private static IPNetwork[] ParseAll(IEnumerable<string> entries) =>
[.. entries.Select(entry => IPNetwork.Parse(entry))];
}
@@ -0,0 +1,722 @@
using System.Buffers;
using System.Net;
using System.Net.WebSockets;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// The relay socket: a ticket in, raw bytes to one host and back.
/// </summary>
/// <remarks>
/// <para>
/// Anonymous at the HTTP layer, because the ticket is the authorization and it is the whole of it. A
/// ticket carries no API authority — it names one host, one set of addresses and one session — so this
/// endpoint can accept it from a client that cannot set an <c>Authorization</c> header on an upgrade,
/// which is the constraint ADR 0004 was written around. It is verified, checked for freshness and
/// redeemed before the upgrade is accepted, so a refused ticket costs a problem document and no socket.
/// </para>
/// <para>
/// <b>Nothing here is scoped to the request.</b> A session lasts as long as a shell does, which is hours,
/// and anything scoped held across it is a pooled database connection held across it. The database is
/// touched three times — redeem, open the audit row, close it — each from a scope of its own that is
/// disposed before the next byte moves. That is ADR 0004's consequence about <c>DbContext</c> lifetime.
/// </para>
/// <para>
/// Backpressure is the await. Each direction reads one buffer, writes it, and does not read again until
/// the write has completed, so a slow reader on either side stops this side reading, which fills the
/// kernel's receive window, which throttles the sender at the far end. Nothing is queued in between, and
/// a session costs its two buffers whatever the peers do.
/// </para>
/// </remarks>
internal sealed class RelayConnectEndpoint(
IServiceScopeFactory scopes,
IRelayNetwork network,
RelayTicketCodec codec,
RelaySessionRegistry sessions,
IOptions<RelayOptions> options,
TimeProvider clock,
IHostApplicationLifetime lifetime,
ILogger<RelayConnectEndpoint> logger)
: EndpointWithoutRequest
{
/// <summary>
/// One direction's buffer.
/// </summary>
/// <remarks>
/// Room for an SSH packet at the size OpenSSH sends them, so a bulk transfer moves in one read and
/// one write per packet rather than several.
/// </remarks>
private const int BufferBytes = 32 * 1024;
/// <summary>How long a close handshake, or a peer's last write, may take before the socket is dropped.</summary>
private static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(5);
/// <inheritdoc />
public override void Configure()
{
Get(RelayProtocol.ConnectPath);
// The ticket is the authorization. See the remarks on this class, and ADR 0004.
AllowAnonymous();
Description(b => b
.WithName("RelayConnect")
.WithSummary("Pipes raw bytes between a WebSocket and the host a relay ticket names.")
.WithTags("Relay"));
}
/// <inheritdoc />
public override async Task HandleAsync(CancellationToken ct)
{
var ticket = await AdmitTicketAsync(ct).ConfigureAwait(false);
if (ticket is null)
{
return;
}
var lease = sessions.TryAdmit(ticket.UserId);
try
{
using var socket = await HttpContext.WebSockets
.AcceptWebSocketAsync(new WebSocketAcceptContext { SubProtocol = RelayProtocol.SubProtocol })
.ConfigureAwait(false);
if (lease is null)
{
RelayLog.LimitReached(logger, ticket.UserId, sessions.Count);
await CloseAsync(
socket,
new Ending(
RelayCloseReason.Unspecified,
RelayProtocol.TooManySessionsCloseCode,
"Too many relay sessions are open."))
.ConfigureAwait(false);
return;
}
if (lifetime.ApplicationStopping.IsCancellationRequested)
{
await CloseAsync(socket, ShuttingDown).ConfigureAwait(false);
return;
}
await RelayAsync(socket, ticket, ct).ConfigureAwait(false);
}
finally
{
// Inside a try that starts before the upgrade, for the reason VaultEventsEndpoint gives: an
// accept that throws would otherwise hold a place against this user's cap forever.
lease?.Dispose();
}
}
private static Ending ShuttingDown { get; } = new(
RelayCloseReason.ServerShutdown,
(int)WebSocketCloseStatus.EndpointUnavailable,
"The server is shutting down.");
/// <summary>
/// Answers everything that is not a redeemable ticket, as ordinary HTTP, before any socket exists.
/// </summary>
/// <returns>The redeemed ticket, or null when a response has been sent.</returns>
/// <remarks>
/// A ticket that is forged, expired, spent, or simply absent all answer 401
/// <c>relay-ticket-invalid</c>, with one remedy — ask for another — and no hint of which it was.
/// </remarks>
private async Task<RelayTicket?> AdmitTicketAsync(CancellationToken cancellationToken)
{
if (!options.Value.Enabled)
{
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status404NotFound,
ProblemCodes.RelayUnavailable,
"This server does not relay. GET /api/v1/meta lists the features it does offer."))
.ConfigureAwait(false);
return null;
}
var offered = HttpContext.WebSockets.WebSocketRequestedProtocols;
if (!HttpContext.WebSockets.IsWebSocketRequest
|| !offered.Contains(RelayProtocol.SubProtocol, StringComparer.Ordinal))
{
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.MalformedRequest,
"This endpoint is a WebSocket. Send an upgrade request offering the "
+ $"'{RelayProtocol.SubProtocol}' subprotocol and the ticket as a second one, "
+ $"'{RelayProtocol.TicketProtocolPrefix}<ticket>'."))
.ConfigureAwait(false);
return null;
}
var sealedTicket = offered
.FirstOrDefault(protocol => protocol.StartsWith(RelayProtocol.TicketProtocolPrefix, StringComparison.Ordinal))
?[RelayProtocol.TicketProtocolPrefix.Length..];
var ticket = sealedTicket is { Length: > 0 } ? codec.TryOpen(sealedTicket) : null;
if (ticket is null || !await RedeemAsync(ticket, cancellationToken).ConfigureAwait(false))
{
RelayLog.TicketRefused(logger, ticket?.SessionId);
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status401Unauthorized,
ProblemCodes.RelayTicketInvalid,
"This relay ticket is not valid: it is expired, already used, or was not issued here. "
+ $"Ask for another from POST {RelayProtocol.TicketsPath}."))
.ConfigureAwait(false);
return null;
}
return ticket;
}
/// <summary>
/// Spends the ticket, or answers false when it has been spent already.
/// </summary>
/// <remarks>
/// <para>
/// A unique insert, which is the only form of "once" that holds across nodes: two redemptions racing
/// on two nodes both attempt the insert and the database lets exactly one of them win.
/// </para>
/// <para>
/// Rows for tickets that can no longer verify are swept on the way. They guard nothing, and sweeping
/// here — on a path that runs once per session rather than once per request — keeps the table the
/// size of the last thirty seconds without a background job to forget about.
/// </para>
/// </remarks>
private async Task<bool> RedeemAsync(RelayTicket ticket, CancellationToken cancellationToken)
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var now = clock.GetUtcNow();
await database.RelayTicketUses
.Where(use => use.ExpiresAtUtc < now)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
database.RelayTicketUses.Add(new RelayTicketUse
{
TicketId = ticket.TicketId,
ExpiresAtUtc = ticket.ExpiresAt,
UsedAtUtc = now,
});
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return true;
}
catch (DbUpdateException exception)
when (string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal))
{
return false;
}
}
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
private async Task RelayAsync(WebSocket socket, RelayTicket ticket, CancellationToken requestAborted)
{
var started = clock.GetUtcNow();
await OpenAuditAsync(ticket, started).ConfigureAwait(false);
var dialled = await DialAsync(ticket, requestAborted).ConfigureAwait(false);
if (dialled is not var (target, address))
{
RelayLog.TargetUnreachable(logger, ticket.SessionId, ticket.HostId);
var unreachable = new Ending(
RelayCloseReason.TargetUnreachable,
RelayProtocol.TargetUnreachableCloseCode,
"The relay could not reach the host.");
await CloseAsync(socket, unreachable).ConfigureAwait(false);
await CloseAuditAsync(ticket.SessionId, null, new Counters(clock, started), unreachable.Reason).ConfigureAwait(false);
return;
}
var counters = new Counters(clock, started);
Ending ending;
await using (target.ConfigureAwait(false))
{
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
ending = await PumpAsync(socket, target, counters, started, requestAborted).ConfigureAwait(false);
}
RelayLog.SessionClosed(
logger,
ticket.SessionId,
ending.Reason,
counters.ClientToTarget,
counters.TargetToClient);
await CloseAuditAsync(ticket.SessionId, address, counters, ending.Reason).ConfigureAwait(false);
}
/// <summary>
/// Connects to the first of the ticket's addresses that answers, within the connect timeout overall.
/// </summary>
/// <remarks>
/// Overall rather than per address, so a name with sixteen dead addresses costs five seconds rather
/// than eighty. In the order resolved, which is the order the operating system's resolver prefers.
/// </remarks>
private async Task<(Stream Target, IPAddress Address)?> DialAsync(
RelayTicket ticket,
CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(options.Value.ConnectTimeout);
foreach (var address in ticket.TargetAddresses)
{
try
{
var target = await network
.ConnectAsync(address, ticket.TargetPort, timeout.Token)
.ConfigureAwait(false);
return (target, address);
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception exception) when (exception is IOException or System.Net.Sockets.SocketException)
{
// The next address, if there is one.
}
}
return null;
}
/// <summary>
/// Runs both directions and the clock until one of them ends the session, then ends it politely.
/// </summary>
/// <remarks>
/// <para>
/// <b>No operation on the WebSocket is ever cancelled.</b> Cancelling a pending receive or send on a
/// .NET WebSocket aborts it, and an aborted socket cannot send the close frame that is the only way
/// a client learns <em>why</em> its session ended — which, for an idle timeout or a drain, is the
/// difference between "reconnect" and "something is broken". So the token stops the target side and
/// the clock; the sender is let finish its last write; the close goes out; and the receiver ends on
/// the client's answering close. Each wait is bounded, and the socket is aborted only when a peer
/// has stopped answering altogether.
/// </para>
/// </remarks>
private async Task<Ending> PumpAsync(
WebSocket socket,
Stream target,
Counters counters,
DateTimeOffset started,
CancellationToken requestAborted)
{
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
var minding = MindAsync(counters, started, pump.Token);
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
var ending = await first.ConfigureAwait(false) ?? new Ending(
RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
await pump.CancelAsync().ConfigureAwait(false);
// The only sender must be done before the close frame is written: a WebSocket permits one send
// at a time and faults permanently on a second.
if (!await SettlesAsync(downstream).ConfigureAwait(false))
{
socket.Abort();
}
await CloseAsync(socket, ending).ConfigureAwait(false);
if (!await SettlesAsync(upstream).ConfigureAwait(false))
{
socket.Abort();
await SettlesAsync(upstream).ConfigureAwait(false);
}
await SettlesAsync(minding).ConfigureAwait(false);
return ending;
}
/// <summary>Client to target: every binary message is the next bytes of the stream.</summary>
private static async Task<Ending?> ClientToTargetAsync(
WebSocket socket,
Stream target,
Counters counters,
CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
try
{
while (true)
{
ValueWebSocketReceiveResult received;
try
{
// Not cancellable, deliberately. See PumpAsync.
received = await socket.ReceiveAsync(buffer.AsMemory(), CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is WebSocketException or OperationCanceledException)
{
return ClientGone;
}
if (received.MessageType == WebSocketMessageType.Close)
{
return ClientGone;
}
// A byte pipe has no text. A client sending some has the wrong idea about this endpoint,
// and forwarding it into an SSH stream would corrupt that stream at best.
if (received.MessageType != WebSocketMessageType.Binary)
{
return new Ending(
RelayCloseReason.Error,
(int)WebSocketCloseStatus.InvalidMessageType,
"The relay carries binary messages only.");
}
try
{
await target.WriteAsync(buffer.AsMemory(0, received.Count), cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (IOException)
{
return TargetGone;
}
counters.AddClientToTarget(received.Count);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>Target to client: every read becomes one binary message.</summary>
private static async Task<Ending?> TargetToClientAsync(
Stream target,
WebSocket socket,
Counters counters,
CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
try
{
while (true)
{
int read;
try
{
read = await target.ReadAsync(buffer.AsMemory(0, BufferBytes), cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (IOException)
{
return TargetGone;
}
if (read == 0)
{
return TargetGone;
}
try
{
// Not cancellable, deliberately. See PumpAsync.
await socket
.SendAsync(
buffer.AsMemory(0, read),
WebSocketMessageType.Binary,
endOfMessage: true,
CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException)
{
return ClientGone;
}
counters.AddTargetToClient(read);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>
/// Ends the session at the idle timeout, the lifetime cap, or the end of a shutdown's drain.
/// </summary>
/// <remarks>
/// <para>
/// A shutdown does not end a session; it starts a countdown. ADR 0004 asks that a
/// <c>docker compose up -d</c> not guillotine live shells, so a session in progress when the server is
/// told to stop is given the drain timeout to finish — most will, because the user was already
/// closing it or the deployment waits — and is closed with 1001 only when that runs out, which tells
/// the client to reconnect rather than to report a fault. The host's own shutdown timeout is set
/// longer than the drain for this to be possible at all.
/// </para>
/// <para>
/// Idle means no bytes in either direction. WebSocket pings are not bytes of the stream and do not
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
/// </para>
/// </remarks>
private async Task<Ending?> MindAsync(Counters counters, DateTimeOffset started, CancellationToken cancellationToken)
{
var settings = options.Value;
var hardDeadline = started + settings.MaxSessionDuration;
DateTimeOffset? drainDeadline = null;
while (!cancellationToken.IsCancellationRequested)
{
var now = clock.GetUtcNow();
if (drainDeadline is null && lifetime.ApplicationStopping.IsCancellationRequested)
{
drainDeadline = now + settings.DrainTimeout;
}
if (now >= hardDeadline)
{
return new Ending(
RelayCloseReason.DurationLimit,
RelayProtocol.DurationLimitCloseCode,
"This relay session reached its maximum lifetime.");
}
if (drainDeadline is { } drain && now >= drain)
{
return ShuttingDown;
}
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
if (now >= idleDeadline)
{
return new Ending(
RelayCloseReason.IdleTimeout,
RelayProtocol.IdleTimeoutCloseCode,
"This relay session carried nothing for the idle timeout.");
}
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
// Woken early by the start of a shutdown, so the drain is measured from when it began rather
// than from whenever the idle clock next came round.
using var wake = drainDeadline is null
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.ApplicationStopping)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
try
{
await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The shutdown began. Round again to start the drain.
}
catch (OperationCanceledException)
{
return null;
}
}
return null;
}
private static Ending ClientGone { get; } =
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
private static Ending TargetGone { get; } =
new(RelayCloseReason.TargetClosed, (int)WebSocketCloseStatus.NormalClosure, "The host closed the connection.");
/// <summary>
/// Opens the audit row, before the target is dialled, so a session that never reached its host is
/// still one the operator can see.
/// </summary>
private async Task OpenAuditAsync(RelayTicket ticket, DateTimeOffset started)
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
database.RelaySessions.Add(new RelaySession
{
Id = ticket.SessionId,
UserId = ticket.UserId,
HostId = ticket.HostId,
VaultId = ticket.VaultId,
TargetHost = ticket.TargetHost,
TargetPort = ticket.TargetPort,
ClientAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
StartedAtUtc = started,
});
// Not the request's token: the row has to be written even for a client that has already gone,
// or a session that was cut off would be the one with no record.
await database.SaveChangesAsync(CancellationToken.None).ConfigureAwait(false);
}
/// <summary>
/// Closes the audit row. A failure is logged and swallowed: the bytes have already moved, and
/// throwing here would only turn a finished session into an unhandled exception.
/// </summary>
private async Task CloseAuditAsync(
Guid sessionId,
IPAddress? address,
Counters counters,
RelayCloseReason reason)
{
try
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var ended = clock.GetUtcNow();
var dialled = address?.ToString();
await database.RelaySessions
.Where(session => session.Id == sessionId)
.ExecuteUpdateAsync(
set => set
.SetProperty(session => session.EndedAtUtc, ended)
.SetProperty(session => session.TargetAddress, dialled)
.SetProperty(session => session.BytesClientToTarget, counters.ClientToTarget)
.SetProperty(session => session.BytesTargetToClient, counters.TargetToClient)
.SetProperty(session => session.CloseReason, reason),
CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is DbUpdateException or NpgsqlException or InvalidOperationException)
{
RelayLog.AuditCloseFailed(logger, sessionId, exception);
}
}
private static async Task CloseAsync(WebSocket socket, Ending ending)
{
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
{
return;
}
using var timeout = new CancellationTokenSource(CloseTimeout);
try
{
await socket
.CloseOutputAsync((WebSocketCloseStatus)ending.Code, ending.Description, timeout.Token)
.ConfigureAwait(false);
}
catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
{
// The peer is already gone. There is nothing to tell it.
}
}
/// <summary>Waits for a loop to finish, for at most the close timeout.</summary>
/// <returns>Whether it finished.</returns>
private static async Task<bool> SettlesAsync(Task loop)
{
try
{
await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
return true;
}
catch (TimeoutException)
{
return false;
}
catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException)
{
return true;
}
}
/// <summary>How a session ended, and what the client is told.</summary>
private sealed record Ending(RelayCloseReason Reason, int Code, string Description);
/// <summary>
/// Byte counts and the time of the last byte, written by the two directions and read by the clock.
/// </summary>
private sealed class Counters(TimeProvider clock, DateTimeOffset started)
{
private long clientToTarget;
private long targetToClient;
private long lastActivityTicks = started.UtcTicks;
internal long ClientToTarget => Interlocked.Read(ref clientToTarget);
internal long TargetToClient => Interlocked.Read(ref targetToClient);
internal DateTimeOffset LastActivity => new(Interlocked.Read(ref lastActivityTicks), TimeSpan.Zero);
internal void AddClientToTarget(int count)
{
Interlocked.Add(ref clientToTarget, count);
Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
}
internal void AddTargetToClient(int count)
{
Interlocked.Add(ref targetToClient, count);
Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
}
}
}
@@ -0,0 +1,94 @@
using DodoSSH.Domain;
namespace DodoSSH.Api.Features.Relay;
/// <summary>Source-generated log messages for the relay.</summary>
/// <remarks>
/// <para>
/// Ids, counts and reasons. Never a ticket, which is a bearer credential for thirty seconds, and never
/// the target's address: that is in the audit table, where retention is the operator's decision, and
/// copying it into a log would make it the log pipeline's decision too.
/// </para>
/// <para>
/// <see cref="TargetRejected"/> is the one place the reason for a refused target is written down. The
/// client is told nothing specific, deliberately — see <c>RelayTicketEndpoint</c> — so this line is how
/// an operator answers "why will the relay not reach this host".
/// </para>
/// </remarks>
internal static partial class RelayLog
{
[LoggerMessage(
EventId = 2301,
Level = LogLevel.Information,
Message = "Relay ticket refused for user {UserId} and host {HostId}: {Reason}.")]
internal static partial void TargetRejected(ILogger logger, Guid userId, Guid hostId, string reason);
[LoggerMessage(
EventId = 2302,
Level = LogLevel.Debug,
Message = "Relay ticket issued to user {UserId} for host {HostId}, session {SessionId}, with "
+ "{AddressCount} permitted address(es).")]
internal static partial void TicketIssued(
ILogger logger,
Guid userId,
Guid hostId,
Guid sessionId,
int addressCount);
/// <remarks>
/// The session id is known only for a ticket that verified and had already been spent, which is the
/// case worth an operator's attention: a replay, or a client retrying a redemption it thinks failed.
/// </remarks>
[LoggerMessage(
EventId = 2303,
Level = LogLevel.Information,
Message = "Relay ticket refused at the socket (session {SessionId}): forged, expired or already used.")]
internal static partial void TicketRefused(ILogger logger, Guid? sessionId);
[LoggerMessage(
EventId = 2304,
Level = LogLevel.Information,
Message = "Relay session refused for user {UserId}: a session limit is met, with {SessionCount} "
+ "open on this node.")]
internal static partial void LimitReached(ILogger logger, Guid userId, int sessionCount);
[LoggerMessage(
EventId = 2305,
Level = LogLevel.Information,
Message = "Relay session {SessionId} could not reach host {HostId} on any permitted address.")]
internal static partial void TargetUnreachable(ILogger logger, Guid sessionId, Guid hostId);
[LoggerMessage(
EventId = 2306,
Level = LogLevel.Debug,
Message = "Relay session {SessionId} opened for user {UserId} to host {HostId}; {SessionCount} "
+ "open on this node.")]
internal static partial void SessionOpened(
ILogger logger,
Guid sessionId,
Guid userId,
Guid hostId,
int sessionCount);
[LoggerMessage(
EventId = 2307,
Level = LogLevel.Debug,
Message = "Relay session {SessionId} closed ({Reason}) after {ClientToTarget} byte(s) up and "
+ "{TargetToClient} down.")]
internal static partial void SessionClosed(
ILogger logger,
Guid sessionId,
RelayCloseReason reason,
long clientToTarget,
long targetToClient);
/// <remarks>
/// A warning rather than an error: the session itself is over and was fine. What is lost is the
/// audit row's second half, which leaves it looking like a session still open.
/// </remarks>
[LoggerMessage(
EventId = 2308,
Level = LogLevel.Warning,
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
}
@@ -0,0 +1,61 @@
using System.Net;
using System.Net.Sockets;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// The two things the relay asks of the network: what a name resolves to, and a connection to an address.
/// </summary>
/// <remarks>
/// <para>
/// A seam for tests, and a narrow one on purpose. <see cref="RelayAddressPolicy"/> is not behind it:
/// whatever this answers, the policy judges, and a test that replaces this still runs every address
/// through the real rules. What a test cannot do without the seam is dial anything, because every
/// address a test machine can reach on its own — loopback — is one the policy refuses outright.
/// </para>
/// </remarks>
internal interface IRelayNetwork
{
/// <summary>Resolves a stored hostname, which may be an address literal.</summary>
Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken);
/// <summary>Opens a TCP connection to one address.</summary>
/// <returns>A stream that owns the connection.</returns>
Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken);
}
/// <summary>The operating system's resolver and sockets.</summary>
internal sealed class SystemRelayNetwork : IRelayNetwork
{
/// <inheritdoc />
public Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken) =>
// A literal parses to itself without a lookup, which is what an address typed into a host
// editor should do — and what makes "10.0.0.5" mean the same thing on every node.
IPAddress.TryParse(host, out var literal)
? Task.FromResult<IPAddress[]>([literal])
: Dns.GetHostAddressesAsync(host, cancellationToken);
/// <inheritdoc />
public async Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
{
// An interactive shell is a stream of keystrokes; Nagle would hold each one back for the
// acknowledgement of the last, which is the latency the user feels as a sticky terminal.
NoDelay = true,
};
try
{
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
}
@@ -0,0 +1,111 @@
using DodoSSH.Api.Setup;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Counts the relay sessions open on this node, and refuses the ones over ADR 0004's limits.
/// </summary>
/// <remarks>
/// <para>
/// Per node, and so per user <em>per node</em>: a user behind a round-robin balancer with three nodes
/// could hold three times the per-user cap. That is accepted rather than fixed, because fixing it means
/// shared state on the hot path of every connect, and the cap exists to stop one account exhausting a
/// node — which a per-node count does exactly.
/// </para>
/// <para>
/// A singleton, because a session outlives the request that opened it. It holds counts and nothing else;
/// the sockets belong to the handlers that opened them.
/// </para>
/// </remarks>
internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
{
private readonly Lock gate = new();
private readonly Dictionary<Guid, int> perUser = [];
private int total;
/// <summary>How many sessions are open on this node.</summary>
internal int Count
{
get
{
lock (gate)
{
return total;
}
}
}
/// <summary>Whether a session for this user would be admitted now.</summary>
/// <remarks>
/// Advisory, for the ticket endpoint: it lets a caller already at the cap hear so as an HTTP status
/// before it has spent a ticket, but only <see cref="TryAdmit"/> decides, since the answer here can
/// be stale by the time the socket arrives — possibly at another node.
/// </remarks>
internal bool HasRoomFor(Guid userId)
{
var settings = options.Value;
lock (gate)
{
return total < settings.MaxConcurrentSessionsTotal
&& perUser.GetValueOrDefault(userId) < settings.MaxConcurrentSessionsPerUser;
}
}
/// <summary>Admits a session, or answers null when a limit is met.</summary>
/// <returns>A lease that gives the place back when disposed.</returns>
internal Lease? TryAdmit(Guid userId)
{
var settings = options.Value;
lock (gate)
{
var held = perUser.GetValueOrDefault(userId);
if (total >= settings.MaxConcurrentSessionsTotal || held >= settings.MaxConcurrentSessionsPerUser)
{
return null;
}
perUser[userId] = held + 1;
total++;
return new Lease(this, userId);
}
}
private void Release(Guid userId)
{
lock (gate)
{
total--;
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
// session open and not of every user who ever had one.
if (perUser[userId] <= 1)
{
perUser.Remove(userId);
}
else
{
perUser[userId]--;
}
}
}
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
internal sealed class Lease(RelaySessionRegistry registry, Guid userId) : IDisposable
{
private int released;
/// <inheritdoc />
public void Dispose()
{
if (Interlocked.Exchange(ref released, 1) == 0)
{
registry.Release(userId);
}
}
}
}
@@ -0,0 +1,171 @@
using System.Net;
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.WebUtilities;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Everything the relay socket needs to know, decided by the ticket endpoint.
/// </summary>
/// <remarks>
/// The addresses are the resolved ones and the socket dials nothing else. There is no hostname for it
/// to resolve again — <see cref="TargetHost"/> is carried for the audit row only — which is what keeps a
/// DNS answer that changes between issue and connect from changing where the bytes go.
/// </remarks>
/// <param name="TicketId">The replay guard's key.</param>
/// <param name="SessionId">The audit row's id, already given to the client.</param>
/// <param name="UserId">Who asked.</param>
/// <param name="HostId">The host it is for.</param>
/// <param name="VaultId">The host's vault at issue.</param>
/// <param name="TargetHost">The hostname as stored, for the audit row.</param>
/// <param name="TargetAddresses">The permitted addresses the name resolved to, in the order to try.</param>
/// <param name="TargetPort">The port.</param>
/// <param name="ExpiresAt">When the ticket stops verifying.</param>
internal sealed record RelayTicket(
Guid TicketId,
Guid SessionId,
Guid UserId,
Guid HostId,
Guid VaultId,
string TargetHost,
IReadOnlyList<IPAddress> TargetAddresses,
int TargetPort,
DateTimeOffset ExpiresAt);
/// <summary>
/// Seals and opens relay tickets.
/// </summary>
/// <remarks>
/// <para>
/// A Data Protection payload, per ADR 0004: authenticated and encrypted, with key rotation handled by
/// the framework, and verifiable on any node that shares the key ring. Encrypted rather than merely
/// signed because the ticket travels in a header every proxy on the way can read, and what it holds is
/// the resolved address of a machine on the operator's network.
/// </para>
/// <para>
/// The expiry is inside the payload and checked against <see cref="TimeProvider"/>, rather than left to
/// the framework's time-limited protector, which reads the system clock directly — the thing this
/// repository bans so that time can be faked in tests.
/// </para>
/// <para>
/// The layout is a version byte and fixed fields, written by hand. Small enough that a serialiser would
/// be the larger half of the code, and a hand layout has no reflection for trimming to break.
/// </para>
/// </remarks>
internal sealed class RelayTicketCodec(IDataProtectionProvider protection, TimeProvider clock)
{
/// <summary>The purpose string, which isolates these payloads from every other protector's.</summary>
private const string Purpose = "DodoSSH.Relay.Ticket";
private const byte LayoutVersion = 1;
/// <summary>More addresses than any real name resolves to, and a bound on what a ticket can carry.</summary>
internal const int MaxAddresses = 16;
private readonly IDataProtector protector = protection.CreateProtector(Purpose);
internal string Seal(RelayTicket ticket)
{
ArgumentNullException.ThrowIfNull(ticket);
using var buffer = new MemoryStream();
using (var writer = new BinaryWriter(buffer))
{
writer.Write(LayoutVersion);
writer.Write(ticket.TicketId.ToByteArray());
writer.Write(ticket.SessionId.ToByteArray());
writer.Write(ticket.UserId.ToByteArray());
writer.Write(ticket.HostId.ToByteArray());
writer.Write(ticket.VaultId.ToByteArray());
writer.Write(ticket.ExpiresAt.ToUnixTimeMilliseconds());
writer.Write(ticket.TargetPort);
writer.Write(ticket.TargetHost);
writer.Write((byte)ticket.TargetAddresses.Count);
foreach (var address in ticket.TargetAddresses)
{
var bytes = address.GetAddressBytes();
writer.Write((byte)bytes.Length);
writer.Write(bytes);
}
}
// Base64url, whose alphabet is all token characters, so it can stand in a subprotocol name.
return WebEncoders.Base64UrlEncode(protector.Protect(buffer.ToArray()));
}
/// <summary>Opens a ticket, or answers null for one that is forged, damaged or expired.</summary>
/// <remarks>
/// One answer for all three, because the caller's remedy is the same — ask for another — and a
/// distinct "expired" would tell a forger which of their attempts had verified.
/// </remarks>
internal RelayTicket? TryOpen(string sealedTicket)
{
byte[] payload;
try
{
payload = protector.Unprotect(WebEncoders.Base64UrlDecode(sealedTicket));
}
catch (Exception exception) when (exception is CryptographicException or FormatException)
{
return null;
}
RelayTicket ticket;
try
{
ticket = Read(payload);
}
catch (Exception exception)
when (exception is EndOfStreamException or ArgumentException or FormatException)
{
return null;
}
return ticket.ExpiresAt > clock.GetUtcNow() ? ticket : null;
}
private static RelayTicket Read(byte[] payload)
{
using var reader = new BinaryReader(new MemoryStream(payload));
if (reader.ReadByte() != LayoutVersion)
{
throw new FormatException("Unknown ticket layout.");
}
var ticketId = new Guid(reader.ReadBytes(16));
var sessionId = new Guid(reader.ReadBytes(16));
var userId = new Guid(reader.ReadBytes(16));
var hostId = new Guid(reader.ReadBytes(16));
var vaultId = new Guid(reader.ReadBytes(16));
var expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(reader.ReadInt64());
var port = reader.ReadInt32();
var host = reader.ReadString();
var count = reader.ReadByte();
if (count is 0 or > MaxAddresses)
{
throw new FormatException("A ticket names between one and sixteen addresses.");
}
var addresses = new IPAddress[count];
for (var index = 0; index < count; index++)
{
var length = reader.ReadByte();
if (length is not (4 or 16))
{
throw new FormatException("An address is four or sixteen bytes.");
}
addresses[index] = new IPAddress(reader.ReadBytes(length));
}
return new RelayTicket(ticketId, sessionId, userId, hostId, vaultId, host, addresses, port, expiresAt);
}
}
@@ -0,0 +1,231 @@
using System.Net;
using System.Net.Sockets;
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain.Authorization;
using DodoSSH.Infrastructure;
using FastEndpoints;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Issues the ticket that lets exactly one relay socket reach exactly one host.
/// </summary>
/// <remarks>
/// <para>
/// Every decision the relay makes is made here, with the full bearer token and the full access model in
/// hand: which host, whether the caller may connect to it, which addresses its name resolves to and
/// which of those the policy permits. The socket that follows decides nothing. It checks that the ticket
/// is genuine, fresh and unused, and dials what the ticket says. See ADR 0004.
/// </para>
/// <para>
/// <b>Every refusal about the target is the same refusal.</b> A host that does not exist, one in a vault
/// the caller cannot reach, one without the relay switched on, one whose name does not resolve, and one
/// whose every address the policy refuses all answer 403 <c>relay-target-rejected</c> with the same
/// sentence. Anyone who can write a host can set its address to anything, so a distinct answer for
/// "resolves, but refused" would turn this endpoint into a scanner for the operator's internal DNS. The
/// real reason goes to the server log, where the operator can read it.
/// </para>
/// </remarks>
internal sealed class RelayTicketEndpoint(
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
DodoDbContext database,
IRelayNetwork network,
RelayTicketCodec codec,
RelaySessionRegistry sessions,
IOptions<RelayOptions> options,
TimeProvider clock,
ILogger<RelayTicketEndpoint> logger)
: Endpoint<RelayTicketRequest, Results<Ok<RelayTicketResponse>, ProblemHttpResult>>
{
/// <summary>What every target refusal says, whatever the reason was.</summary>
private const string RejectedDetail =
"The relay will not connect to this host. Check that the relay is switched on for it and that its "
+ "address is one this server may reach; the server's log records which.";
/// <inheritdoc />
public override void Configure()
{
Post(RelayProtocol.TicketsPath);
// Enrolled, matching sync. A relayed host is still a host whose name, username and key are
// ciphertext, so a caller with no identity key has no way to have learned which host to ask for.
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("IssueRelayTicket")
.WithSummary("Issues a single-use ticket for one relay connection to one host.")
.WithTags("Relay"));
}
/// <inheritdoc />
public override async Task<Results<Ok<RelayTicketResponse>, ProblemHttpResult>> ExecuteAsync(
RelayTicketRequest req,
CancellationToken ct)
{
var settings = options.Value;
if (!settings.Enabled)
{
return Problems.Coded(
StatusCodes.Status404NotFound,
ProblemCodes.RelayUnavailable,
"This server does not relay. Connect to the host directly; GET /api/v1/meta lists the "
+ "features this server does offer.");
}
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var (target, refusal) = await FindTargetAsync(user.Id, req, ct).ConfigureAwait(false);
if (target is not var (hostname, port, vaultId))
{
return Rejected(user.Id, req.HostId, refusal!);
}
// Before resolving rather than after: a caller already at the cap is told so without this server
// spending a DNS query on a ticket that could not be redeemed.
if (!sessions.HasRoomFor(user.Id))
{
RelayLog.LimitReached(logger, user.Id, sessions.Count);
return Problems.Coded(
StatusCodes.Status429TooManyRequests,
ProblemCodes.RelayLimitReached,
"You have as many relay sessions open as this server allows. Close one and try again.");
}
var addresses = await ResolvePermittedAsync(hostname, ct).ConfigureAwait(false);
if (addresses is not { Length: > 0 })
{
return Rejected(user.Id, req.HostId, addresses is null
? "its name did not resolve"
: "every address its name resolved to is refused by policy");
}
var ticket = new RelayTicket(
TicketId: Guid.CreateVersion7(),
SessionId: Guid.CreateVersion7(),
UserId: user.Id,
HostId: req.HostId,
VaultId: vaultId,
TargetHost: hostname,
TargetAddresses: addresses,
TargetPort: port,
ExpiresAt: clock.GetUtcNow() + settings.TicketLifetime);
RelayLog.TicketIssued(logger, user.Id, req.HostId, ticket.SessionId, addresses.Length);
return TypedResults.Ok(new RelayTicketResponse(
Ticket: codec.Seal(ticket),
WebSocketUrl: new Uri(settings.WebSocketUrl!, UriKind.Absolute),
SubProtocol: RelayProtocol.SubProtocol,
SessionId: ticket.SessionId,
ExpiresAt: ticket.ExpiresAt));
}
/// <summary>
/// Finds the host, and decides whether this caller may be relayed to it at all.
/// </summary>
/// <returns>The stored target, or the reason there is none — for the log, never for the caller.</returns>
private async Task<((string Hostname, int Port, Guid VaultId)? Target, string? Refusal)> FindTargetAsync(
Guid userId,
RelayTicketRequest req,
CancellationToken cancellationToken)
{
if (req.PortForwardId is not null)
{
return (null, "a port forward was requested, which v1 does not relay");
}
var host = await database.Hosts
.AsNoTracking()
.SingleOrDefaultAsync(h => h.Id == req.HostId && h.DeletedAtUtc == null, cancellationToken)
.ConfigureAwait(false);
if (host is null)
{
return (null, "no such host");
}
var access = await vaultAccess.ResolveAsync(userId, host.VaultId, cancellationToken).ConfigureAwait(false);
// Connect rather than Read, because Connect is the permission that says what this is. Every role
// that reads today also connects — see VaultAccessService.ForRole — but a role that ever split
// them should be refused a relay here without anybody remembering to look.
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Connect))
{
return (null, "the caller may not connect to this host's vault");
}
// The CHECK constraint makes the second half unreachable while the first is true. Tested anyway,
// because the cost of trusting it is dialling a null.
if (!host.RelayEnabled || host.Hostname is not { } hostname || host.Port is not { } port)
{
return (null, "the relay is not switched on for this host");
}
return RelayAddressPolicy.PermitsPort(port)
? ((hostname, port, host.VaultId), null)
: (null, $"port {port} is refused by policy");
}
/// <summary>
/// Resolves the stored name, and keeps the addresses the policy permits.
/// </summary>
/// <returns>The permitted addresses, possibly none; or null when the name did not resolve.</returns>
/// <remarks>
/// <para>
/// Filtered rather than refused as a whole when one answer is bad. A name that resolves to a public
/// address and a link-local one is dialled on the public address, and never on the other — the
/// ticket carries only what passed, so there is nothing else for the socket to try.
/// </para>
/// <para>
/// The addresses kept are the normalised ones, so what is dialled is exactly what was judged rather
/// than a second spelling of it.
/// </para>
/// </remarks>
private async Task<IPAddress[]?> ResolvePermittedAsync(string hostname, CancellationToken cancellationToken)
{
var policy = new RelayAddressPolicy(options.Value);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(options.Value.ConnectTimeout);
IPAddress[] resolved;
try
{
resolved = await network.ResolveAsync(hostname, timeout.Token).ConfigureAwait(false);
}
catch (SocketException)
{
return null;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return null;
}
return
[
.. resolved
.Where(policy.Permits)
.Select(RelayAddressPolicy.Normalise)
.Distinct()
.Take(RelayTicketCodec.MaxAddresses),
];
}
private ProblemHttpResult Rejected(Guid userId, Guid hostId, string reason)
{
RelayLog.TargetRejected(logger, userId, hostId, reason);
return Problems.Coded(StatusCodes.Status403Forbidden, ProblemCodes.RelayTargetRejected, RejectedDetail);
}
}
@@ -73,6 +73,7 @@ internal static class ItemKinds
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
new HostGroupKind(), new TagKind(), new SnippetKind(),
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
new SpelunkKeyKind(),
}.ToDictionary(kind => kind.WireType);
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
@@ -1093,3 +1094,98 @@ internal sealed class ObjectStoreKind : IItemKind
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Spelunk keys: an envelope and nothing else.</summary>
/// <remarks>
/// <see cref="ObjectStoreKind"/>'s rules, because it holds the same class of thing: an API key is a password,
/// and the address beside it is where somebody manages their servers. Nothing on the server calls Spelunk.
/// </remarks>
internal sealed class SpelunkKeyKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.SpelunkKey;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.SpelunkKey;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.SpelunkKeys.SingleOrDefaultAsync(s => s.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.SpelunkKeys
.Where(s => s.VaultId == vaultId && ids.Contains(s.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var key = new VaultSpelunkKey { Id = id, VaultId = vaultId };
database.SpelunkKeys.Add(key);
return key;
}
/// <summary>Refuses every plaintext field there is.</summary>
/// <remarks>
/// The relay fields are refused although this type holds an address, as they are for a bucket: the
/// address belongs in the ciphertext, and the server has no business keeping a list of where its users
/// manage their servers from.
/// </remarks>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A Spelunk key is not something the server dials; its address stays encrypted.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A Spelunk key's contents are inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A Spelunk key carries no plaintext fields at all.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
+3
View File
@@ -49,6 +49,9 @@ builder.Services.AddSingleton<VaultEventHub>();
builder.Services.AddSingleton<IVaultEventPublisher>(
provider => provider.GetRequiredService<VaultEventHub>());
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
builder.Services.AddDodoRelay();
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
// DbContext, and a singleton would capture one for the lifetime of the process.
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
+27 -3
View File
@@ -54,6 +54,13 @@ internal static class Configuration
+ "setting exists to provide.")
.ValidateOnStart();
services.AddRelayOptions();
return services;
}
/// <summary>The relay's options, which carry more rules than any other section. See ADR 0004.</summary>
private static void AddRelayOptions(this IServiceCollection services) =>
services.AddOptions<RelayOptions>()
.BindConfiguration(RelayOptions.SectionName)
.ValidateDataAnnotations()
@@ -63,11 +70,28 @@ internal static class Configuration
.Validate(
options => options.IdleTimeout < options.MaxSessionDuration,
"Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.")
.Validate(
options => options.TicketLifetime > TimeSpan.Zero
&& options.TicketLifetime <= TimeSpan.FromSeconds(30),
"Relay:TicketLifetime must be positive and at most 30 seconds. A ticket is a bearer "
+ "credential in a header; ADR 0004 bounds how long one may be worth stealing.")
.Validate(
options => options.ConnectTimeout > TimeSpan.Zero && options.DrainTimeout >= TimeSpan.Zero,
"Relay:ConnectTimeout must be positive and Relay:DrainTimeout must not be negative.")
.Validate(
options => Features.Relay.RelayAddressPolicy.AreNetworks(options.AllowedNetworks)
&& Features.Relay.RelayAddressPolicy.AreNetworks(options.DeniedNetworks),
"Relay:AllowedNetworks and Relay:DeniedNetworks must hold CIDR networks such as "
+ "10.20.0.0/16, with no host bits set. An entry this server cannot read would be a gap "
+ "the operator believes is closed.")
.Validate(
options => !options.Enabled
|| (Uri.TryCreate(options.WebSocketUrl, UriKind.Absolute, out var url)
&& (string.Equals(url.Scheme, Uri.UriSchemeWs, StringComparison.Ordinal)
|| string.Equals(url.Scheme, Uri.UriSchemeWss, StringComparison.Ordinal))),
"Relay:WebSocketUrl must be an absolute ws:// or wss:// URL.")
.ValidateOnStart();
return services;
}
/// <summary>
/// Warns about configuration that is individually valid but dangerous in combination.
/// </summary>
+39 -2
View File
@@ -115,13 +115,50 @@ public sealed class RelayOptions
/// Whether RFC1918 and other private ranges may be dialled.
/// </summary>
/// <remarks>
/// <para>
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
/// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. Loopback,
/// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
/// </para>
/// <para>
/// Governs the private ranges only — RFC1918, carrier-grade NAT (which is where a Tailnet lives) and
/// IPv6 unique-local — and never the list <c>RelayAddressPolicy</c> refuses with no configuration
/// escape: loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses.
/// Nothing here can re-open those, which is what ADR 0004 §3 means by non-overridable.
/// </para>
/// </remarks>
public bool AllowPrivateNetworks { get; set; } = true;
/// <summary>
/// Networks, in CIDR form, that a relay target must fall inside. Empty means any network the other
/// rules permit.
/// </summary>
/// <remarks>
/// When set, this is the whole answer for the ranges it names: a target inside one is permitted even
/// with <see cref="AllowPrivateNetworks"/> off, because "only 10.20.0.0/16" is the precise thing an
/// operator means by setting both. It still cannot reach the non-overridable list, and
/// <see cref="DeniedNetworks"/> still wins over it.
/// </remarks>
public IList<string> AllowedNetworks { get; } = [];
/// <summary>Networks, in CIDR form, that a relay target may never fall inside.</summary>
/// <remarks>
/// Checked before <see cref="AllowedNetworks"/>, so a deny inside an allow is a hole in it — the way
/// round that lets an operator carve the database subnet out of an otherwise permitted range.
/// </remarks>
public IList<string> DeniedNetworks { get; } = [];
/// <summary>
/// A directory to keep the ticket key ring in. Unset means the platform default.
/// </summary>
/// <remarks>
/// Losing the key ring is harmless in the way losing <c>Sync:CursorSigningKey</c> is: a ticket lives
/// thirty seconds, so a restart invalidates at most the connects in flight and the client asks
/// again. What does need it is more than one node — every node must be able to read every other's
/// tickets for round-robin to work, so a multi-node deployment points this at shared storage.
/// </remarks>
public string? KeyRingDirectory { get; set; }
/// <summary>Maximum concurrent sessions per user.</summary>
[Range(1, 1000)]
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
@@ -1,6 +1,7 @@
using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Relay;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams;
using DodoSSH.Contracts;
@@ -45,6 +46,8 @@ internal static class EndpointRegistration
typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint),
typeof(VaultEventsEndpoint),
typeof(RelayTicketEndpoint),
typeof(RelayConnectEndpoint),
typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint),
typeof(UpdateTeamEndpoint),
@@ -65,7 +68,6 @@ internal static class EndpointRegistration
// Registered as each feature lands:
// Identity — key rotation, passphrase change
// Vaults — per-item ACLs
// Relay — tickets and the WebSocket
// Audit, Admin
});
+59
View File
@@ -0,0 +1,59 @@
using DodoSSH.Api.Features.Relay;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.DataProtection.Repositories;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Setup;
/// <summary>The relay's services. See ADR 0004.</summary>
internal static class RelaySetup
{
/// <summary>
/// How much longer than the drain the host waits before it stops regardless.
/// </summary>
/// <remarks>
/// Room for the close handshakes and the audit rows' final writes once the drain has run out, which
/// is the part of a shutdown an operator would otherwise lose without noticing.
/// </remarks>
private static readonly TimeSpan ShutdownMargin = TimeSpan.FromSeconds(10);
internal static IServiceCollection AddDodoRelay(this IServiceCollection services)
{
// Singletons: a session outlives the request that opened it, a protector is safe to share, and
// the network seam holds no state.
services.AddSingleton<RelayTicketCodec>();
services.AddSingleton<RelaySessionRegistry>();
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
// The application name isolates this deployment's payloads from any other ASP.NET application
// sharing a key directory, and — unlike the default, which is derived from the content root —
// is the same on every node, which is what lets any node verify any node's ticket.
services.AddDataProtection().SetApplicationName("DodoSSH");
services.AddOptions<KeyManagementOptions>()
.Configure<IOptions<RelayOptions>, ILoggerFactory>((keys, relay, loggers) =>
{
if (relay.Value.KeyRingDirectory is { Length: > 0 } directory)
{
keys.XmlRepository = new FileSystemXmlRepository(new DirectoryInfo(directory), loggers);
}
});
// Longer than the drain, or the host would stop waiting for live sessions before the drain ADR 0004
// promises them had run. Harmless with the relay off: nothing else holds a request open that long,
// and the host stops as soon as the last request has.
services.AddOptions<HostOptions>()
.Configure<IOptions<RelayOptions>>((host, relay) =>
{
var wanted = relay.Value.DrainTimeout + ShutdownMargin;
if (wanted > host.ShutdownTimeout)
{
host.ShutdownTimeout = wanted;
}
});
return services;
}
}
+87 -24
View File
@@ -86,36 +86,77 @@ 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;
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
//
// The transfer count is real now that the document picker gives this head a way to start one, and
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
// moves five files.
//
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
// because it is subscribed to the workspace, which lives as long as the process.
var keepAlive = new SessionKeepAlive(
workspace,
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
// announces transfers appearing and finishing. Without this the notification would come up when an
// upload started and stay up after it finished, which is the failure this class exists to prevent.
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
keepAlive.Refresh();
// Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
// does and why.
ComposeKeepAlive(workspace, viewModel);
return shell;
}
/// <summary>
/// Wires up the foreground service that makes <c>TerminalWorkspace</c>'s promise — that a shell outlives
/// a vault lock — true on a platform that stops backgrounded processes.
/// </summary>
/// <remarks>
/// <para>
/// Split out of <see cref="Compose"/> for length rather than for reuse; there is exactly one caller.
/// </para>
/// <para>
/// The transfer count is real now that the document picker gives this head a way to start one, and it
/// matters exactly when nobody is looking: a shell survives backgrounding because somebody opened it,
/// and an upload has to survive precisely when nobody is — the screen is off and the phone is in a
/// pocket. Queued counts as active, so putting five files in the queue and locking the phone moves five
/// files. <c>holdsFileSession</c> covers the third case a count alone cannot: a host connected on the
/// Files screen with no transfer moving is still a live SFTP session that backgrounding would sever, and
/// <c>TransfersViewModel.HasLiveFileSession</c> is the existing fact — <c>IsConnected</c> with a real
/// cipher, which a bucket never has — that answers whether one is open.
/// </para>
/// <para>
/// <c>keepAlive</c> is a local rather than a field, matching the desktop head: an Avalonia Application
/// has no disposal hook, so a field holding a disposable would have nowhere honest to release it. It
/// stays alive because it is subscribed to the workspace, which lives as long as the process.
/// </para>
/// </remarks>
private static void ComposeKeepAlive(TerminalWorkspace workspace, MainWindowViewModel viewModel)
{
var keepAlive = new SessionKeepAlive(
workspace,
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession,
listeningForwards: () => viewModel.ListeningForwardCount);
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
// Without this the notification would come up when an upload started and stay up after it
// finished, which is the failure this class exists to prevent.
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
// The half that was missing until now: a shell opening. SessionKeepAlive already heard the
// workspace announce a session ending, but nothing announced the opposite — a user who opened a
// shell and backgrounded the app had no foreground service at all, because the only wire in was the
// one for taking it down. TerminalSessionOpened is that other half, forwarded from
// VaultViewModel.SessionOpened, and without this line the service could never come up for a shell
// in the first place, which was precisely the promise this whole arrangement exists to keep.
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
// A forward starting, stopping or failing changes the notification's wording and nothing else — it
// rides a shell's connection, so the shell is already what holds the service up. The user is still
// entitled to know a phone in their pocket is holding ports open.
viewModel.ForwardsChanged += (_, _) => keepAlive.Refresh();
keepAlive.Refresh();
}
/// <summary>
/// Writing to this phone's clipboard.
/// </summary>
@@ -147,6 +188,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.
@@ -157,7 +218,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.
@@ -207,6 +269,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
deviceName: PhoneEnvironment.DeviceName,
copyToClipboard: copyToClipboard,
readClipboard: readClipboard,
updates: updates);
@@ -1,6 +1,7 @@
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
using Avalonia;
using Avalonia.Android;
using Avalonia.Media;
using DodoSSH.Client.Android.Platform;
using global::Android.App;
using global::Android.Runtime;
@@ -46,6 +47,30 @@ public sealed class DodoSshAndroidApplication : AvaloniaAndroidApplication<DodoS
}
/// <inheritdoc />
/// <remarks>
/// This used to be the one place the two heads disagreed on purpose: the recorded v2 decision was
/// "Android recolours with the shared palette and keeps its own default sans", on the reasoning that a
/// phone's system font is a phone's own business. The user has reversed that, this pass — the phone now
/// takes the desktop's face as well as its colours, and <c>docs/design-import-gaps.md</c> is corrected
/// to say so rather than left asserting a decision that no longer holds.
///
/// <c>WithInterFont</c> still registers Inter, for the same reason <c>Program.cs</c> keeps it on the
/// desktop: it is what the layout suite pins and what a glyph Montserrat does not cover falls back to.
/// What changes is which face answers first. Montserrat ships embedded in
/// <c>DodoSSH.Client.Shell/Assets/Fonts</c> already — this project references that assembly for
/// <c>Theme/Phone.axaml</c>'s own <c>MonoFont</c>, so no csproj or asset work was needed to reach it
/// from here, only the same <see cref="FontManagerOptions"/> block the desktop's
/// <c>Program.BuildAvaloniaApp</c> sets.
/// </remarks>
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
base.CustomizeAppBuilder(builder).WithInterFont();
base.CustomizeAppBuilder(builder)
.WithInterFont()
.With(new FontManagerOptions
{
DefaultFamilyName = "avares://DodoSSH.Client.Shell/Assets/Fonts#Montserrat",
FontFallbacks =
[
new FontFallback { FontFamily = new FontFamily("avares://Avalonia.Fonts.Inter/Assets#Inter") },
],
});
}
@@ -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 />
@@ -298,6 +298,19 @@ internal sealed class AndroidUpdateChannel : IUpdateChannel
/// and a grant on every launch, all so the installer can read a file this application already has
/// open — where a session hands it the bytes directly.
/// </para>
/// <para>
/// ◆ <b>The pending intent handed to <c>Commit</c> is not a notification, and treating it as one is
/// why this silently installed nothing.</b> An application holding <c>REQUEST_INSTALL_PACKAGES</c>
/// rather than the privileged <c>INSTALL_PACKAGES</c> never gets a decision back from a commit: what
/// the platform sends first is <c>STATUS_PENDING_USER_ACTION</c>, carrying — in
/// <c>Intent.EXTRA_INTENT</c> — the activity that draws the confirmation. Android does not draw it on
/// its own. Something has to receive that broadcast and start it, and for the life of this feature
/// nothing did: the intent named a bare action string with no receiver behind it, so the session was
/// written, committed, and left staged forever while the user watched nothing happen. See
/// <see cref="InstallSessionReceiver"/>, which is that something, and note that the intent naming it
/// must be explicit — a mutable pending intent wrapping an implicit one is refused outright from
/// API 34, which is where this build's target sits.
/// </para>
/// </remarks>
public void ApplyAndRestart(AvailableUpdate update)
{
@@ -318,7 +331,19 @@ internal sealed class AndroidUpdateChannel : IUpdateChannel
return;
}
var installer = packages.PackageInstaller;
HandToTheInstaller(packages.PackageInstaller, path);
}
/// <summary>Writes the fetched APK into a session and commits it.</summary>
/// <remarks>
/// The session is abandoned on any failure, which is not tidiness. One that is created and neither
/// committed nor abandoned stays staged, holding the space its <c>SetSize</c> reserved, and Android
/// caps how many an application may have open at once — so a fault that repeats, which is exactly
/// what a broken updater is, ends up unable to create a session at all. That would be a second and
/// wholly unrelated symptom for the same cause, and the more confusing of the two.
/// </remarks>
private void HandToTheInstaller(PackageInstaller installer, string path)
{
var parameters = new PackageInstaller.SessionParams(PackageInstallMode.FullInstall);
var length = new FileInfo(path).Length;
@@ -326,8 +351,10 @@ internal sealed class AndroidUpdateChannel : IUpdateChannel
var id = installer.CreateSession(parameters);
using (var session = installer.OpenSession(id))
try
{
using var session = installer.OpenSession(id);
using (var destination = session.OpenWrite(InstallSession, 0, length))
using (var source = File.OpenRead(path))
{
@@ -339,20 +366,41 @@ internal sealed class AndroidUpdateChannel : IUpdateChannel
session.Fsync(destination);
}
// A pending intent is how the platform reports what the user decided, and one is required
// whether or not anything listens. Nothing here does: the two outcomes are this process being
// replaced and this process carrying on, and both are already visible without being told.
// Mutable is required from API 31 — the installer fills the result in — and does not exist
// Mutable is required from API 31 — the installer fills the status in — and does not exist
// below it, where every pending intent is mutable and naming the flag will not compile
// against the older platform. minSdk here is 28, so both cases are real.
var flags = OperatingSystem.IsAndroidVersionAtLeast(31)
? PendingIntentFlags.Mutable | PendingIntentFlags.UpdateCurrent
: PendingIntentFlags.UpdateCurrent;
var callback = PendingIntent.GetBroadcast(context, 0, new Intent(InstallSession), flags);
// Named at the receiver class rather than carrying an action string, and both halves of that
// matter. An explicit intent is the only kind a mutable pending intent may wrap from API 34,
// so the implicit one that was here throws before commit is even reached on any current
// phone; and a broadcast with a receiver behind it is what gets the confirmation drawn at
// all. See the remarks above.
var callback = PendingIntent.GetBroadcast(
context,
0,
new Intent(context, typeof(InstallSessionReceiver)),
flags);
session.Commit(callback!.IntentSender!);
}
catch
{
try
{
installer.AbandonSession(id);
}
catch (Java.Lang.Throwable)
{
// The session could not be abandoned either. Whatever went wrong first is the useful
// half of that, so it is the one allowed to propagate — a cleanup failure thrown from
// here would replace the cause with a consequence.
}
throw;
}
}
/// <summary>Which release this build's channel reads.</summary>
@@ -432,6 +480,93 @@ internal sealed class AndroidUpdateChannel : IUpdateChannel
}
}
/// <summary>
/// Starts the confirmation Android hands back when an install session is committed.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the whole of "the platform draws the confirmation".</b> It does, but only once asked: a
/// commit by an application with <c>REQUEST_INSTALL_PACKAGES</c> answers
/// <c>STATUS_PENDING_USER_ACTION</c> and puts the activity that draws the dialogue in
/// <c>Intent.EXTRA_INTENT</c>, for the application to start. Nothing else in the system will start it.
/// Without a receiver the commit still succeeds, the session still stages, and the update simply never
/// arrives — which is precisely how this shipped.
/// </para>
/// <para>
/// Every other status is ignored on purpose, and that part of the original reasoning survives: the two
/// outcomes worth knowing are this process being replaced and this process carrying on, and both are
/// already visible without being told. A failure the user caused by declining is not an error, and a
/// failure Android caused says so in its own dialogue.
/// </para>
/// <para>
/// Not exported. The only sender is the pending intent this application handed to its own commit, which
/// the system delivers under this application's identity — so nothing outside needs to reach it, and an
/// exported receiver that starts an activity out of an extra is a component anybody could use to launch
/// an arbitrary screen with this application's package on it.
/// </para>
/// </remarks>
[BroadcastReceiver(Enabled = true, Exported = false)]
internal sealed class InstallSessionReceiver : BroadcastReceiver
{
/// <inheritdoc />
public override void OnReceive(Context? context, Intent? intent)
{
if (context is null || intent is null)
{
return;
}
// Defaulted to a failure rather than to the pending value, so a broadcast arriving without a
// status is treated as nothing to do instead of as a reason to go looking for an intent.
var status = intent.GetIntExtra(
PackageInstaller.ExtraStatus,
(int)PackageInstallStatus.Failure);
if (status != (int)PackageInstallStatus.PendingUserAction)
{
return;
}
if (ConfirmationIn(intent) is not { } confirmation)
{
return;
}
// The activity if there is one, and the application context otherwise with a task of its own —
// the same choice, for the same reason, as SendToTheUnknownSourcesScreen above. A receiver's own
// context cannot start an activity without the flag, and the update loop can perfectly well have
// committed this while the application was backgrounded.
if (PhoneEnvironment.CurrentActivity is { } activity)
{
activity.StartActivity(confirmation);
return;
}
confirmation.AddFlags(ActivityFlags.NewTask);
context.StartActivity(confirmation);
}
/// <summary>The activity Android wants started, read the way the running platform allows.</summary>
/// <remarks>
/// The untyped overload is obsolete from API 33 and the typed one does not exist below it, so both
/// are here behind the guard the analyser reads. The same shape as the pending-intent flags in
/// <see cref="AndroidUpdateChannel"/>, and for the same reason: minSdk is 28, so both branches run
/// on phones this ships to.
/// </remarks>
private static Intent? ConfirmationIn(Intent intent)
{
if (OperatingSystem.IsAndroidVersionAtLeast(33))
{
return intent.GetParcelableExtra(
Intent.ExtraIntent,
Java.Lang.Class.FromType(typeof(Intent))) as Intent;
}
return intent.GetParcelableExtra(Intent.ExtraIntent) as Intent;
}
}
/// <summary>
/// Picks the update channel this copy of the phone head gets.
/// </summary>
@@ -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;
}
}
@@ -6,7 +6,7 @@ using global::Android.OS;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Keeps the process alive for as long as a shell or a transfer is live.
/// Keeps the process alive for as long as a shell, a transfer, or a connected Files session is live.
/// </summary>
/// <remarks>
/// <para>
@@ -41,6 +41,26 @@ internal sealed class SessionForegroundService : Service
private const string ChannelId = "dodossh.sessions";
private const int NotificationId = 1;
// API 33+ requires the request to name a code the RequestPermissionsResult callback would be handed
// back — 1 is fine because this head never implements that callback at all, see
// RequestNotificationPermission's own remark for why a result is not worth listening for.
private const int NotificationPermissionRequestCode = 1;
/// <summary>
/// Whether <see cref="OnStartCommand"/> has run for this process without a matching
/// <see cref="OnDestroy"/> since — i.e. whether Android currently considers this service foregrounded.
/// </summary>
/// <remarks>
/// Volatile because <see cref="Reconcile"/> can run on whatever thread called
/// <see cref="SessionKeepAlive.Refresh"/>, while this is set from the binder thread Android delivers
/// service lifecycle callbacks on — two threads with no other synchronisation between them, and a stale
/// read here is the difference between updating a notification in place and calling
/// <c>StartForegroundService</c> on a service that is already running, which is what defect 3 was.
/// </remarks>
private static volatile bool running;
private static bool notificationPermissionRequested;
/// <remarks>
/// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
/// wanted here: the point is that they outlive whatever the user does with the interface.
@@ -49,7 +69,9 @@ internal sealed class SessionForegroundService : Service
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
{
StartForeground(NotificationId, BuildNotification(intent?.GetStringExtra("summary") ?? "Working"));
running = true;
StartForeground(NotificationId, BuildNotification(this, intent?.GetStringExtra("summary") ?? "Working"));
// NotSticky: if Android does kill this process, the SSH connections died with it and there is
// nothing to resume. Restarting the service would produce a notification claiming sessions that no
@@ -58,14 +80,36 @@ internal sealed class SessionForegroundService : Service
return StartCommandResult.NotSticky;
}
/// <inheritdoc />
/// <remarks>
/// The other half of <see cref="running"/>. Android calls this whether the service stopped itself or
/// was stopped from outside — <see cref="Reconcile"/>'s down case calls <c>StopService</c> rather than
/// clearing the flag directly, so this override is the one place that actually knows the service has
/// gone, matching how <see cref="OnStartCommand"/> is the one place that knows it has come up.
/// </remarks>
public override void OnDestroy()
{
running = false;
base.OnDestroy();
}
/// <remarks>
/// <para>
/// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android
/// requires one, and because the user is entitled to know the app is holding connections open. Making
/// it buzz would be a notification about nothing having happened.
/// </para>
/// <para>
/// Static, taking the <see cref="Context"/> it needs rather than reading <c>this</c>: the instance path
/// through <see cref="OnStartCommand"/> passes the service itself, and the in-place update path through
/// <see cref="Reconcile"/> has no service instance at all — only <see cref="PhoneEnvironment.Require"/>
/// — because posting to an already-running notification never touches the service's own lifecycle.
/// </para>
/// </remarks>
private Notification BuildNotification(string summary)
private static Notification BuildNotification(Context context, string summary)
{
var manager = (NotificationManager)GetSystemService(NotificationService)!;
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
if (OperatingSystem.IsAndroidVersionAtLeast(26))
{
@@ -79,12 +123,12 @@ internal sealed class SessionForegroundService : Service
}
var reopen = PendingIntent.GetActivity(
this,
context,
0,
new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
new Intent(context, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
return new Notification.Builder(this, ChannelId)
return new Notification.Builder(context, ChannelId)
.SetContentTitle("DodoSSH")
.SetContentText(summary)
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
@@ -93,37 +137,150 @@ internal sealed class SessionForegroundService : Service
.Build();
}
/// <summary>Starts or stops the service to match what is actually running.</summary>
/// <summary>Starts, stops, or refreshes the service's notification to match what is actually running.</summary>
/// <param name="liveSessions">Shells with a live channel behind them.</param>
/// <param name="activeTransfers">Transfers still moving bytes.</param>
public static void Reconcile(int liveSessions, int activeTransfers)
/// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
/// <param name="listeningForwards">
/// Port forwards listening over those shells. Named in the summary and nothing else: a forward rides a
/// shell's connection, so it cannot be the reason the process has to stay up — see
/// <see cref="SessionKeepAlive"/>.
/// </param>
/// <remarks>
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
/// always. Something live and the service not yet running starts it. Something live and the service
/// already running is the case that used to call <c>StartForegroundService</c> a second time, which on
/// API 31+ throws <c>ForegroundServiceStartNotAllowedException</c> the instant the app is backgrounded
/// — a transfer finishing in the pocket, one of two shells dying — crashing the process and taking the
/// remaining connections with it. That case — running, and the app backgrounded — now only posts a
/// fresh notification through the <see cref="NotificationManager"/> already holding the channel open,
/// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
/// start even over a service that looks like it is running; the inline remark below is why.
/// </remarks>
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
{
var context = PhoneEnvironment.Require();
var intent = new Intent(context, typeof(SessionForegroundService));
if (liveSessions == 0 && activeTransfers == 0)
if (liveSessions == 0 && activeTransfers == 0 && !holdsFileSession)
{
context.StopService(intent);
context.StopService(new Intent(context, typeof(SessionForegroundService)));
return;
}
// The summary says what is actually held, counted rather than generic — the same principle the
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
intent.PutExtra("summary", Summarise(liveSessions, activeTransfers));
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession, listeningForwards);
context.StartForegroundService(intent);
// In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
// always allowed and is preferred even when `running` says the service is up: a disconnect followed
// by a quick reconnect can land here while the StopService just issued is still in flight, and
// posting to that dying service's notification would leave an orphan receipt over an unprotected
// process — restarting instead makes the flag's small lag harmless. A start on a service that
// really is running only re-delivers OnStartCommand, whose StartForeground updates the same
// notification anyway. CurrentActivity is the foreground signal: set on resume, cleared on pause.
if (running && PhoneEnvironment.CurrentActivity is null)
{
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
manager.Notify(NotificationId, BuildNotification(context, summary));
return;
}
private static string Summarise(int liveSessions, int activeTransfers)
RequestNotificationPermission(context);
Start(context, summary);
}
/// <remarks>
/// Split out of <see cref="Reconcile"/> for the try/catch alone, which needs its own remark and would
/// otherwise crowd the three-way branch above it.
/// </remarks>
private static void Start(Context context, string summary)
{
var parts = new List<string>(2);
var intent = new Intent(context, typeof(SessionForegroundService));
intent.PutExtra("summary", summary);
try
{
context.StartForegroundService(intent);
}
catch (Java.Lang.IllegalStateException)
{
// ForegroundServiceStartNotAllowedException (API 31+) derives from this, and reaching it here
// means a start-worthy transition — a shell opening, a Files connection completing, the first
// transfer landing in an empty queue — happened while the app was backgrounded, which is
// precisely when Android refuses a new foreground start. There is no retry that helps: by the
// time this catch runs, the moment such a start would have been allowed has already passed.
// Swallowing it is the honest choice and not just the available one — letting the exception
// propagate would crash the process and drop the very shells and transfers this service exists
// to keep alive. A process that keeps running unprotected outlives one that does not run at all.
}
}
/// <summary>
/// Asks for the receipt notification's own permission, the first time this process actually has
/// something to show rather than at launch.
/// </summary>
/// <remarks>
/// At most once per process, via <see cref="notificationPermissionRequested"/> — not to work around a
/// platform limit, since Android already refuses to show the dialogue twice, but because a second call
/// to <c>RequestPermissions</c> after the first is still pending is its own kind of noise. No result is
/// read back: there is nothing this class would do differently for a grant versus a refusal, so a
/// callback would exist only to be empty. What refusal costs is stated rather than hidden — the
/// notification stays invisible — and what it does not cost is the point: the service still starts,
/// still holds the process in the foreground, and the shells and transfers it protects are exactly as
/// safe as if the user had said yes. See the manifest's own comment on this permission.
/// </remarks>
private static void RequestNotificationPermission(Context context)
{
// Isolated as its own guard clause rather than folded into the compound condition below: the
// platform-compatibility analyzer only recognises a version check as guarding what follows when it
// is the sole condition of its own early return, and PostNotifications is annotated API 33+.
if (!OperatingSystem.IsAndroidVersionAtLeast(33))
{
return;
}
// Read into a local rather than referenced again inside the lambda below: the guard clause above
// covers a direct call in this method's own body, but the platform-compatibility analyzer treats a
// lambda as reachable from anywhere and will not extend the guard across that boundary. Capturing
// the already-validated string sidesteps the false positive without weakening the actual check.
var postNotifications = global::Android.Manifest.Permission.PostNotifications;
if (notificationPermissionRequested
|| PhoneEnvironment.CurrentActivity is not { } activity
|| context.CheckSelfPermission(postNotifications) == Permission.Granted)
{
return;
}
notificationPermissionRequested = true;
activity.RunOnUiThread(() =>
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
}
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
{
var parts = new List<string>(4);
if (liveSessions > 0)
{
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
}
// Beside the shells they run over, and only while one is: a count left over from a session that has
// just ended would be a receipt for ports nothing holds open any more.
if (liveSessions > 0 && listeningForwards > 0)
{
parts.Add(listeningForwards == 1 ? "1 port forwarded" : $"{listeningForwards} ports forwarded");
}
if (holdsFileSession)
{
parts.Add("Files connected");
}
if (activeTransfers > 0)
{
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
@@ -17,37 +17,71 @@ namespace DodoSSH.Client.Android.Platform;
/// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which
/// a counter incremented on open and decremented on close would not.
/// </para>
/// <para>
/// Three facts feed <see cref="SessionForegroundService.Reconcile"/>, not one: live shells, moving
/// transfers, and an idle-but-connected Files session. The last of those used to be missing entirely —
/// a shell survives backgrounding because somebody opened it, but a Files connection with nothing moving
/// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that
/// gap closed, read the same way the other two facts are: asked, not cached.
/// </para>
/// <para>
/// Listening port forwards are a fourth fact, and a different kind: they only change what the notification
/// says. A forward runs over a shell's connection and stops with it, so there is never a listening forward
/// without a live shell already holding the service up — and a count that could start the service on its own
/// would only ever be wrong, in the moment a forward's Stopped had not been heard yet. What they add is the
/// receipt: the user is entitled to know the phone is holding ports open, as well as shells.
/// </para>
/// </remarks>
internal sealed class SessionKeepAlive : IDisposable
{
private readonly TerminalWorkspace workspace;
private readonly Func<int> activeTransfers;
private readonly Func<bool> holdsFileSession;
private readonly Func<int> listeningForwards;
/// <param name="workspace">The live shells.</param>
/// <param name="activeTransfers">
/// How many transfers are moving bytes. A delegate rather than a queue, because file transfer is out
/// of this head's first scope — see the decision in docs/android-port.md — and this is the seam it
/// will arrive through rather than a dependency taken before there is anything to depend on.
/// How many transfers are moving bytes. A delegate rather than a queue, because ownership of the
/// transfer queue stays with <c>TransfersViewModel</c> — this class only ever asks it a question.
/// </param>
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers)
/// <param name="holdsFileSession">
/// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but-
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
/// </param>
/// <param name="listeningForwards">
/// How many port forwards are listening, across every tab. For the notification's wording only; see the
/// class remarks. See <c>MainWindowViewModel.ListeningForwardCount</c>.
/// </param>
public SessionKeepAlive(
TerminalWorkspace workspace,
Func<int> activeTransfers,
Func<bool> holdsFileSession,
Func<int> listeningForwards)
{
this.workspace = workspace;
this.activeTransfers = activeTransfers;
this.holdsFileSession = holdsFileSession;
this.listeningForwards = listeningForwards;
// Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is
// a binder call and needs no particular thread. Nothing here touches the interface.
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
// closer's own — which is fine: starting and stopping a service is a binder call and needs no
// particular thread. Nothing here touches the interface. That the announcement waits for the run to
// actually complete, and comes for deliberate closes too, is what makes reading LiveSessionCount
// from it honest — the event's own remark carries the stuck notification that taught us both.
workspace.SessionEnded += OnSessionEnded;
}
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
/// <remarks>
/// Called after anything that could change either count — opening a shell, closing a tab, a transfer
/// finishing. Calling it when nothing changed is free: reconciling to the state it is already in is
/// either a redundant <c>startForegroundService</c> on a running service or a <c>stopService</c> on a
/// stopped one, and Android treats both as no-ops.
/// Called after anything that could change any of the facts — opening a shell, closing a tab, a transfer
/// finishing, a Files connection opening or closing, a forward starting or stopping. Calling it when nothing changed is free:
/// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
/// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one,
/// and Android treats all of those as no-ops.
/// </remarks>
public void Refresh() =>
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers());
SessionForegroundService.Reconcile(
workspace.LiveSessionCount, activeTransfers(), holdsFileSession(), listeningForwards());
/// <inheritdoc />
public void Dispose()
@@ -56,7 +90,7 @@ internal sealed class SessionKeepAlive : IDisposable
// The notification goes with the composition root. Leaving it up over a process that is shutting
// down is how an SSH client acquires a reputation for a notification you cannot get rid of.
SessionForegroundService.Reconcile(0, 0);
SessionForegroundService.Reconcile(0, 0, false, 0);
}
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
@@ -12,7 +12,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- The service's persistent notification. Runtime-requested on API 33+, and refusal is survivable. -->
<!--
The service's persistent notification. Requested on API 33+ from SessionForegroundService.Reconcile,
the first time in this process there is actually something to show — not at launch, where the ask
would justify nothing on screen yet. Refusal is survivable: the service still starts and still holds
the process in the foreground either way, so a "no" costs the notification and nothing else.
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The launcher mark, and it is the same mark PhoneShell's header and the desktop titlebar draw:
>_ in the canvas colour on a solid accent tile. Filled rather than outlined since v2.
>_ in AccentInk on a solid accent tile. Filled rather than outlined since v2.
The tile is not in this file. It is the background layer — @color/dodo_accent, see ic_launcher.xml
— and that is the whole trick of the filled design on Android: the rounding a launcher applies is
@@ -10,10 +10,10 @@
rounded shape inside the first, visibly clipped at the corners on any device whose mask is not the
one it was drawn for.
#0E1220 is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
Android resource cannot reference a XAML dictionary. It equals Canvas today and is named
separately in the palette for a reason worth keeping in mind here: this is ink on the accent, not
the window behind it, and it follows AccentInk if the two ever part.
#FFFFFF is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
Android resource cannot reference a XAML dictionary. Through v2 it equalled Canvas and this file
followed it when the two parted: v5's ink on the accent is white, the way every accent surface in
that palette carries white, while the window behind it went its own way to #05050A.
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer 18 on each
edge is what the launcher eats for masking and parallax. The glyph spans 36.06..71.94, which is
@@ -35,7 +35,7 @@
<path
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
android:fillColor="#00000000"
android:strokeColor="#0E1220"
android:strokeColor="#FFFFFF"
android:strokeWidth="5.4"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
@@ -44,7 +44,7 @@
<path
android:pathData="M54,64.68 L71.94,64.68"
android:fillColor="#00000000"
android:strokeColor="#0E1220"
android:strokeColor="#FFFFFF"
android:strokeWidth="5.4"
android:strokeLineCap="round" />
@@ -11,7 +11,7 @@
Worth shipping rather than leaving out: a launcher with themed icons on and no monochrome
layer to use falls back to the full-colour icon, so the one app on the home screen still
drawn in blue is this one.
drawn in its own accent is this one.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
@@ -9,12 +9,12 @@
between an Android resource and a XAML resource dictionary, so the duplication is stated rather than
hidden.
-->
<color name="dodo_window">#0E1220</color>
<color name="dodo_window">#05050A</color>
<!--
AccentColor from the same palette, here because the launcher icon's background layer is a colour
and not a drawable. Same hand-kept duplication as above, and the same rule: if the palette moves,
this moves with it.
-->
<color name="dodo_accent">#5B8CFF</color>
<color name="dodo_accent">#5D42DE</color>
</resources>
+309 -21
View File
@@ -12,10 +12,28 @@
the alternative, and the red one is the one that costs something.
── v2 ──────────────────────────────────────────────────────────────────────────────────────────────
The second design rounds everything. The corner radii below are the design's own — 4 for a tag, 9 for a
button or a pill, 10 for a list row, 11 for the search well, 12 for a card, 14 for a block of
monospaced output — and they are a ladder rather than a set of preferences: the radius says how big the
thing is, so a 12 on a chip or a 4 on a card reads as the wrong size before it reads as the wrong shape.
The second design rounded everything on its own ladder — 4 for a tag, 9 for a button or a pill, 10 for
a list row, 11 for the search well, 12 for a card, 14 for a block of monospaced output — and the numbers
below carried it for three passes: the radius said how big the thing was, so a 12 on a chip or a 4 on a
card read as the wrong size before it read as the wrong shape. Left as a record of that reasoning rather
than deleted, because the reasoning still holds; only the numbers it was reasoning about have moved.
── v5 ──────────────────────────────────────────────────────────────────────────────────────────────
This pass takes the desktop's v5 ladder instead of v2's own, on the same reversal recorded for the
fonts: the phone now matches the desktop's shape as well as its face. Three rungs rather than six —
cards and sections stay 12, a button or a field is 10, a chip or a tag is 6 — collapsing v2's 9/10/11
into the one value the desktop's buttons and fields already use, and moving its 4 up to 6 and its 14
down to 12 to land on the desktop's own chip and card numbers. The ladder still says how big a thing
is before it says what shape it is; it is just a shorter ladder now, because the desktop it is copying
never drew v2's 11-radius search well or a card any rounder than a chip's neighbour a step away, and the
same case that closed the gap between 9, 10 and 11 closes the one between 12 and 14.
Not every radius on this head belongs to this ladder. The floating action button is 28 — half its own
56, which is a circle rather than a ladder rung — and the sheets stay at 22 on their top corners only,
which is a phone idiom this codebase's own bottom sheets have used since v2 and the desktop draws
nothing like. Both are documented where they are set rather than here, for the reason FAB and sheet
radii are always documented locally: a reader who only ever meets one of them should not have to find
this paragraph to learn it was deliberate.
-->
<Style Selector="Button.primary">
@@ -23,15 +41,32 @@
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{StaticResource Accent}" />
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!--
◆ Gradient and glow, replacing a flat Accent fill — the same swap App.axaml's Button.accent made on the
desktop, and for the same reason: this design draws every primary button top-to-bottom from a lighter
violet into the accent proper, lifted off the surface with a soft violet glow rather than a border. Both
live in Palette.axaml as AccentGradient and AccentGlow, already resolvable here because the palette is
shared — this is a resource-key swap, not new colour.
Background and BoxShadow are set here rather than as plain Setters above, because Fluent's default
button template only lets a style reach the fill and the glow through the ContentPresenter it draws
itself around — the same reason the desktop's rule targets /template/ ContentPresenter rather than the
Button. Height, radius and the rest stay ordinary Setters on Button.primary itself; only the two the
template intercepts move down here.
-->
<Style Selector="Button.primary /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
<Setter Property="BoxShadow" Value="{StaticResource AccentGlow}" />
</Style>
<Style Selector="Button.primary:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Accent}" />
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
<Setter Property="Opacity" Value="0.82" />
</Style>
@@ -39,9 +74,15 @@
Disabled is drawn as flat and unlit rather than merely dimmed. The design's CONTINUE button on the
recovery screen is disabled until the checkbox is ticked, and a user who cannot tell it is disabled
reads the screen as broken rather than as waiting for them.
BoxShadow has to be cleared here too, as "none" rather than left unset — see the remark on
Button.accent:disabled in the desktop's App.axaml for why the literal string is required and an empty
BoxShadows is not: the base rule's glow Setter is still in effect wherever a more specific one does not
override it, and a disabled primary button lit with a glow would read as wanting to be pressed.
-->
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Raised}" />
<Setter Property="BoxShadow" Value="none" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
</Style>
@@ -54,7 +95,7 @@
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11.5" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -69,17 +110,33 @@
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource Danger}" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse. -->
<!--
A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse.
◆ VerticalContentAlignment, because that height is the whole point of this class and Avalonia's default
for content alignment is Stretch — so the content presenter stretched the caption to the full row and a
TextBlock draws its line at the TOP of what it is given. Most rows here never showed it, having a
StackPanel or a Grid of already-centred children in them, which is what made the four that did look
like four unrelated mistakes: the breadcrumb chips and the up-one-directory button on FilesScreen, and
TerminalScreen's CLOSE THIS TAB, each a bare TextBlock in a row 36 or 44 tall with no vertical padding.
Measured at those numbers, the caption sat flush against the top edge with 21 to 33 pixels below it.
The desktop head's App.axaml carries the same setter on its own shapes for the same reason, and excludes
two of them — see the remark on Button.ghost there. Nothing is excluded here: no row's content depends
on being stretched, there being no full-height strip inside any of the thirty-three, and the Grids that
stop filling hold only children that already centre themselves, so they land where they always did.
-->
<Style Selector="Button.row">
<Setter Property="MinHeight" Value="54" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="10" />
@@ -110,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
@@ -126,7 +297,17 @@
Accent-filled, which it shares with Button.primary and with nothing else — and it means the same thing
in both places: the one action on the surface that is not a choice between peers. Circular by radius
rather than by a Path, so the pressed state the template draws is the same shape as the button.
rather than by a Path, so the pressed state the template draws is the same shape as the button — 28,
exactly half its own 56, which is a geometric constraint rather than a ladder rung and does not move
with the rest of this file's radii.
◆ Gradient and glow since v5, matching Button.primary rather than staying a flat fill once that one
moved. The two are the only accent-filled controls on this head and are read as one idea — "the thing
this surface wants you to do" — so a flat FAB beside a gradient primary button would be the seam this
codebase's palette file keeps warning about, one screenshot over from the button it echoes. The glow
reads as well on a floating circle as it does on a bar-anchored rectangle: if anything a control that
already floats over content earns a lift more than one sitting in a row of chrome does, so it is kept
rather than dropped for the FAB's own shape.
Still only on HOSTS. The design puts a second one on S3 and that editor genuinely does not exist yet, so
the style being here is not permission to draw one there.
@@ -135,7 +316,6 @@
<Setter Property="Width" Value="56" />
<Setter Property="Height" Value="56" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="{StaticResource Accent}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="28" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
@@ -145,8 +325,14 @@
<Setter Property="FontSize" Value="24" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- See the remark above Button.primary's own /template/ ContentPresenter rule: BoxShadow has no home on
a Button itself, so the fill moves down here alongside it rather than staying a plain Setter. -->
<Style Selector="Button.fab /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
<Setter Property="BoxShadow" Value="{StaticResource AccentGlow}" />
</Style>
<Style Selector="Button.fab:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Accent}" />
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
<Setter Property="Opacity" Value="0.82" />
</Style>
@@ -161,7 +347,7 @@
interface glitching rather than as a tap being received.
-->
<Style Selector="Button.scrim">
<Setter Property="Background" Value="#9E0E1220" />
<Setter Property="Background" Value="#9E05050A" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="0" />
@@ -169,10 +355,10 @@
<Setter Property="VerticalAlignment" Value="Stretch" />
</Style>
<Style Selector="Button.scrim:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#9E0E1220" />
<Setter Property="Background" Value="#9E05050A" />
</Style>
<Style Selector="Button.scrim:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="#9E0E1220" />
<Setter Property="Background" Value="#9E05050A" />
</Style>
<!--
@@ -194,7 +380,7 @@
-->
<Style Selector="Border.output">
<Setter Property="Background" Value="{StaticResource TerminalSurface}" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="12" />
</Style>
@@ -205,7 +391,7 @@
-->
<Style Selector="Border.tag">
<Setter Property="Background" Value="{StaticResource Chip}" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="7,2" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
@@ -231,7 +417,7 @@
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="11,0" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
@@ -267,11 +453,17 @@
Checked is a filled surface with accent *text*, not an accent fill. v2 makes that distinction
everywhere — see the remark on AccentText in Palette.axaml — and a chip is where it matters most: a row
of four solid blue lozenges is a row of four things that all look like the primary action.
◆ Its own radius rather than Border.tag's or Button.chiptoggle's, despite the name. v2 drew this control
on the ladder's button-or-pill rung rather than its tag rung — 9, not 4 — and the v5 pass carries the
same rung forward to 10 rather than to Border.tag's 6: it is a much larger control at 34 tall against a
tag's line-height, and a radius picked for a 34-pixel chip is not the one a 20-pixel tag needs, whatever
the class is called. See the remark on Phone.axaml's own ladder, above.
-->
<Style Selector="RadioButton.chip">
<Setter Property="MinHeight" Value="34" />
<Setter Property="Padding" Value="13,6" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
@@ -314,7 +506,59 @@
<Setter Property="Background" Value="{StaticResource Field}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="11" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<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" />
@@ -358,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}" />
@@ -370,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">
<!--
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,11 +185,50 @@
</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="9"
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
Command="{Binding RemoteUpCommand}">
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
</Button>
@@ -188,7 +238,7 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CrumbViewModel">
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="9"
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="10"
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
CommandParameter="{Binding Path}">
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
@@ -199,10 +249,50 @@
</StackPanel>
</ScrollViewer>
<!-- ============ the listing ============ -->
<Panel Grid.Row="4">
<!-- ============ pins ============ -->
<!--
◆ The phone's answer to the desktop's QUICK ACCESS sidebar — the same PinnedPathList this host was
connected with, drawn where this head actually browses files rather than beside a terminal it has no
strip for. See TransfersViewModel.ConnectedPinnedPaths for why this is a snapshot taken at connect
rather than a live follow of the host row: a pin added or removed mid-session shows up here on the
next connect, not while this one is still open.
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
Its own row rather than folded into the breadcrumb above: the two scroll independently and mean
different things — the breadcrumb is where this pane is, the chips are places it can jump to — and a
shared row would make ↑ look like one more pin among several.
Gone rather than empty when nothing is pinned or nothing is connected, which HasConnectedPins already
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="5" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" Margin="14,0" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!--
Classes="row" at its default 44dp MinHeight rather than the breadcrumb's own 36 — these chips
are the destination, not the trail behind it, and a target worth a deliberate tap gets the
full touch floor this head holds everything else to. Not trimmed: a chip scrolls sideways with
the row rather than being squeezed to fit it, so the full path is always what a tap commits to.
-->
<Button Classes="row" MinHeight="44" Padding="11,0" CornerRadius="10"
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
CommandParameter="{Binding}">
<TextBlock Classes="mono" FontSize="12" Text="{Binding}" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- ============ the listing ============ -->
<Panel Grid.Row="6">
<TextBlock Classes="body" IsVisible="{Binding ShowsRemoteInvitation}" Margin="24,12"
VerticalAlignment="Top" Text="{Binding Status}" />
<!--
@@ -219,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">
@@ -236,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">
@@ -255,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>
@@ -264,8 +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 ============ -->
<Border Grid.Row="5" IsVisible="{Binding IsConnected}" Background="{StaticResource Chrome}"
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
<Border Grid.Row="8" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
<StackPanel Spacing="9">
@@ -287,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
@@ -308,7 +551,7 @@
Foreground="{StaticResource Accent}"
Background="{StaticResource Raised}" />
<TextBlock Classes="detail" Text="{Binding Progress}"
TextTrimming="CharacterEllipsis" />
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
@@ -345,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)
@@ -0,0 +1,65 @@
<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.Android.Views.HostActionBar"
x:DataType="vm:VaultViewModel">
<!--
◆ THE CONTEXTUAL ACTION BAR, WHICH IS WHAT THE CONNECT CARD BECAME.
A long press on a host used to raise a card over the bottom of the list: a password box, CONNECT, EDIT,
MOVE and DELETE. It was a menu drawn as a form, in the one part of the screen a list grows into — so it
covered rows, it had room for five things and never a sixth, and everything on it was about exactly one
machine. What replaced it is a selection and this bar.
It takes the vault header's place rather than sitting under it, which is the arrangement Android has used
for this since contextual action bars existed and is the reason it can be unambiguous: while it is up, the
screen is about the ticked hosts and about nothing else. See PhoneShell.ShowsHostSelectionBar, which is
where the swap happens and why it is computed rather than bound.
Left to right: the cross that leaves selection mode, the count, then the two controls. The pencil is out
in front of the menu because editing is the thing people reach for most and is worth not opening a menu
for; the other six are behind the ⋯, which is a sheet rather than a flyout — see
VaultViewModel.IsHostActionSheetOpen.
── one host or several ──────────────────────────────────────────────────────────────────────────────────
The pencil is drawn only while exactly one host is ticked, and it is collapsed rather than greyed. Three
of the seven actions are like that — edit, connect, and browse — and the reason is the same for all
three: a form, a terminal and a file session are each about one machine, and there is no sensible reading
of "edit these six". The three that stay are the ones a count makes better rather than worse.
-->
<!-- DeepChrome rather than Chrome, since v5: this bar takes the vault header's own place, so it takes
the vault header's own surface too — see the remark on that header in PhoneShell.axaml. -->
<Border Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="6,0" Height="56">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<!--
The cross, and it is the first thing on the bar for the reason back arrows are: the way out of a
mode belongs at the edge the thumb reaches without crossing anything it might press by mistake.
-->
<Button Grid.Column="0" Classes="icon" Content="✕"
Command="{Binding ClearHostChoiceCommand}"
ToolTip.Tip="Stop choosing hosts" />
<!--
The count, and only the count. The bar it sits in is already saying what the number is about, and
"6 hosts selected" beside a pencil and a menu is the width those two icons need at 360dp.
-->
<TextBlock Grid.Column="1" Classes="heading" FontSize="17" Margin="8,0"
VerticalAlignment="Center" Text="{Binding ChosenHostsLabel}" />
<Button Grid.Column="2" Classes="icon" Content="✎" FontSize="15"
IsVisible="{Binding HasOneChosenHost}"
Command="{Binding EditChosenHostCommand}"
ToolTip.Tip="Edit this host" />
<Button Grid.Column="3" Classes="icon" Content="⋯" FontSize="18"
Command="{Binding OpenHostActionSheetCommand}"
ToolTip.Tip="More things to do with these hosts" />
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>The bar that takes the header's place while hosts are chosen.</summary>
internal sealed partial class HostActionBar : UserControl
{
public HostActionBar() => AvaloniaXamlLoader.Load(this);
}
@@ -17,6 +17,13 @@
and the plan names presenting it as dismissible as the one design mistake that matters here. A phone
makes this easy to get wrong, because a bottom sheet is swipe-to-dismiss by convention; this is not a
sheet for that reason, it is a full-screen panel.
◆ WHAT IS BEHIND IT IS NO LONGER ALWAYS THE HOST LIST. The shell used to move to HOSTS before letting a
handshake ask this, because the desktop head drew the question as a banner on that screen; both heads draw
it over the surface now, so this is raised over whatever the user was on — including the connect box on the
Connections surface, which is where a machine that is in no keychain at all is dialled from. Nothing here
changes for that, and that is the point: this control never knew which screen it was over. See
MainWindowViewModel.OnVaultConnectionFailed and HostKeyCard.axaml on the desktop.
-->
<Panel>
@@ -29,7 +36,7 @@
written out because the palette holds no alpha variant of a surface — see QuickConnect on the
desktop, which carries the same note.
-->
<Border Background="#9E0E1220" />
<Border Background="#9E05050A" />
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
@@ -42,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}"
@@ -107,8 +131,15 @@
<!--
The only control on the screen, and it goes back rather than forward. Compare the sheet above,
where the accent button connects.
◆ IT SAID "BACK TO HOSTS", AND IT NEITHER WENT THERE NOR CAME BACK. Two things were wrong with
it at once. RejectHostKey cleared only the pending key, so pressing it left HasHostKeyMismatch
set and this opaque panel up over whatever the user navigated to next — including the host editor
the paragraph above sends them to. And the shell no longer moves to Hosts to ask the question, so
even fixed it does not land there: it lowers this panel and reveals the screen that was already
underneath. "BACK" is what that is. See VaultViewModel.RejectHostKey.
-->
<Button Classes="secondary" Content="BACK TO HOSTS" Margin="0,24,0,0"
<Button Classes="secondary" Content="BACK" Margin="0,24,0,0"
Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
File diff suppressed because it is too large. Load diff
@@ -1,23 +1,26 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design 02 — the host list, and the connect bar that replaces the desktop's right column.</summary>
/// <summary>Design 02 — the host list, and the two gestures that are the whole of this file.</summary>
/// <remarks>
/// <para>
/// ◆ <b>Two gestures on one list, and this file is the whole of the difference between them.</b> A tap on a
/// host connects to it; a long press asks about it, which is what raises the bar. Why they were split is on
/// the screen itself; what is here is the mechanics, and there are two of them worth knowing.
/// ◆ <b>A tap connects, a long press chooses, and once anything is chosen a tap ticks instead.</b> The
/// branch in the middle of that is why the tap is handled here rather than bound in the markup: which of
/// the two a tap means depends on whether the screen is in selection mode, and Avalonia's bindings cannot
/// ask.
/// </para>
/// <para>
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain <c>Grid</c>.
/// A button as the item template swallows the press before the list sees it — <c>FilesScreen</c> writes that
/// out at length — leaving nothing selected and every control that reads the selection doing nothing. Both
/// events fire after the list has moved its selection, which is what lets these read it.
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain
/// <c>Border</c>. A button as the item template swallows the press before the list sees it —
/// <c>FilesScreen</c> writes that out at length — so neither handler would ever run.
/// </para>
/// </remarks>
internal sealed partial class HostsScreen : UserControl
@@ -27,18 +30,18 @@ internal sealed partial class HostsScreen : UserControl
/// </summary>
/// <remarks>
/// Avalonia raises <c>Tapped</c> on release whatever the press lasted, so without this a long press
/// would open the bar and then connect — the two gestures firing one after the other on one touch, which
/// is the one outcome that would make both of them untrustworthy. Set when the hold starts and cleared
/// by the tap it suppresses, so it never survives the gesture that set it.
/// would tick the row and then the tap it becomes would untick it again — one touch doing a thing and
/// undoing it, which is the one outcome that would make both gestures untrustworthy. Set when the hold
/// starts and cleared by the tap it suppresses, so it never survives the gesture that set it.
/// </remarks>
private bool held;
/// <remarks>
/// <para>
/// The long press is attached here rather than in the markup so that it sits beside the property that
/// makes it fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than assumed:
/// it is the whole of the gesture, and a default that changed would take it away silently — every tap
/// would go on working and nothing would ever open the bar again.
/// Both gestures are attached here rather than in the markup so that they sit beside the property that
/// makes one of them fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than
/// assumed: it is the whole of the long press, and a default that changed would take it away silently —
/// every tap would go on working and nothing would ever choose a host again.
/// </para>
/// <para>
/// ◆ <b><c>FindControl</c> rather than the field the name generator declares for <c>x:Name</c></b>,
@@ -62,20 +65,20 @@ internal sealed partial class HostsScreen : UserControl
}
/// <summary>
/// Connects to the row that was tapped.
/// Connects to the row that was tapped, or ticks it where hosts are already ticked.
/// </summary>
/// <remarks>
/// <para>
/// Guarded on the selection being a host rather than on what was under the finger. A tap on a group
/// heading moves the list's selection and the view model bounces it straight back to whatever was chosen
/// before — see <c>VaultViewModel.SelectedSidebarRow</c> — so reading the selection here answers "a host,
/// or nothing" without this file needing to know that rule. The cost of getting it wrong is connecting to
/// a machine the user was not pointing at.
/// ◆ <b>The branch is the selection, not the row.</b> Once anything is chosen the screen is in selection
/// mode — the bar across the top says so — and in that mode a tap adds and removes rather than
/// connecting. That is what every Android list does, and the alternative is worse than a rule to
/// remember: a tap that connected while five machines sat ticked would open a terminal on top of a
/// selection somebody was halfway through building.
/// </para>
/// <para>
/// Fire-and-forget, as the desktop grid's activation is: the command reports its own failures onto the
/// status line — an unknown host key, a refused password — and awaiting it here would be an event handler
/// returning a task nothing observes.
/// Fire-and-forget where it connects, as the desktop grid's activation is: the command reports its own
/// failures onto the status line — an unknown host key, a refused password — and awaiting it here would
/// be an event handler returning a task nothing observes.
/// </para>
/// </remarks>
private void OnRowTapped(object? sender, TappedEventArgs e)
@@ -86,25 +89,25 @@ internal sealed partial class HostsScreen : UserControl
return;
}
if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
if (DataContext is not VaultViewModel vault || RowUnder(e) is not { } row)
{
_ = vault.ConnectToRowCommand.ExecuteAsync(row);
}
return;
}
/// <summary>
/// Raises the bar about the row that was held.
/// </summary>
if (vault.IsChoosingHosts)
{
vault.ToggleHostChoiceCommand.Execute(row);
return;
}
_ = vault.ConnectToRowCommand.ExecuteAsync(row);
}
/// <summary>Puts a tick against the row that was held, entering selection mode with it.</summary>
/// <remarks>
/// <para>
/// On <see cref="HoldingState.Started"/> rather than on completion, so the bar is up while the finger is
/// still down. A long press that showed nothing until release would be a gesture with no way to tell it
/// had been recognised, and the only feedback available on this list is the thing it does.
/// </para>
/// <para>
/// It goes through <c>OpenHostPaneCommand</c>, which is the desktop's own "ask about this host" — the two
/// heads raise different furniture from one flag rather than keeping a selection rule each.
/// </para>
/// </remarks>
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
{
@@ -115,9 +118,33 @@ internal sealed partial class HostsScreen : UserControl
held = true;
if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
if (DataContext is VaultViewModel vault && RowUnder(e) is { } row)
{
vault.OpenHostPaneCommand.Execute(row);
vault.ChooseHostCommand.Execute(row);
}
}
/// <summary>
/// The host the gesture landed on, or null where it landed on something that is not one.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Read off the element under the finger rather than off the list's selection, which is what both
/// handlers used to do.</b> The selection was defensible while a tap only ever connected: a tap on a
/// group heading moves the selection and the view model bounces it straight back — see
/// <c>VaultViewModel.SelectedSidebarRow</c> — so reading it answered "a host, or nothing" for free.
/// It stops being defensible the moment a tap can tick one: a heading would then bounce the selection to
/// whichever host was last chosen and tick <em>that</em>, which is a machine the user was not pointing
/// at going into a set they are about to delete.
/// </para>
/// <para>
/// The ancestor rather than <c>e.Source</c> itself, because the source is whatever leaf the finger
/// landed on — a tag chip's <c>TextBlock</c> has a string for a data context, and the row's own
/// <c>Ellipse</c> has the row. The <see cref="ListBoxItem"/> is the one element in that chain whose data
/// context is always the list's item, whatever kind it is.
/// </para>
/// </remarks>
private static HostRowViewModel? RowUnder(RoutedEventArgs e) =>
(e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true)?.DataContext
as HostRowViewModel;
}
@@ -149,8 +149,19 @@
</StackPanel>
</StackPanel>
<Button Grid.Column="2" Classes="danger" Height="44" Width="104" Content="DELETE"
<!--
MOVE beside it, and only where there is somewhere to move to — the rule the host's MOVE follows on
this head, for the reason a phone has: there is no room to draw a button that answers with a
refusal. It is the ghost of the pair rather than the danger one, because a move is undone by
moving it back.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
<Button Classes="secondary" Height="44" Width="86" Content="MOVE"
IsVisible="{Binding CanMoveSelectedItem}"
Command="{Binding MoveSelectedItemCommand}" />
<Button Classes="danger" Height="44" Width="104" Content="DELETE"
Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
</Grid>
</Border>
@@ -180,6 +191,47 @@
</StackPanel>
</Border>
<!-- ============ ◆ moving it to another vault ============ -->
<!--
The desktop's panel, in the place the deletion question uses and never at the same time as it: MOVE
disarms a pending deletion on the way in, and the buttons that ask either question are hidden while
one is up.
Both sentences are here rather than only in the status line afterwards, which on a phone is one line at
the bottom of a screen somebody has already navigated away from. The second one is the count of what
points at this key — every one of them is re-aimed at the vault it moves to, and that is the part
nobody can see from a keychain row.
-->
<Border Grid.Row="3" IsVisible="{Binding IsMovingItem}" Margin="12,4"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="12" Padding="14,12">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
Text="{Binding MovingItemLabel}" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding MoveItemVaultChoices}"
SelectedItem="{Binding SelectedMoveItemVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body" TextWrapping="Wrap"
Text="It is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody in the vault it leaves can." />
<TextBlock Classes="body" TextWrapping="Wrap"
IsVisible="{Binding HasMovingItemUsage}"
Text="{Binding MovingItemUsage}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveItemCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveItemCommand}" />
</Grid>
</StackPanel>
</Border>
<!-- ============ the items ============ -->
<Panel Grid.Row="4">
@@ -235,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"
@@ -91,8 +91,8 @@
Not Border.card, and neither are its two counterparts on HOSTS and FILES: that class is a panel,
and this is a warning, so the background and the border are the warn pair rather than the chrome
one. What it does take is the radius the v2 ladder gives anything card-sized, which is what the
other two already draw.
one. What it does take is the radius the ladder gives anything card-sized — 12, unchanged from v2
to v5 — which is what the other two already draw.
-->
<Border IsVisible="{Binding HasLiveSessions}" Margin="0,22,0,0"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
@@ -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}"
@@ -189,14 +189,18 @@
◆ Port forwarding, stated rather than drawn.
The v2 design gives this hub a sixth entry and a full screen behind it: local, remote and dynamic
rules with toggles and byte counters. Nothing in this application forwards anything — ISshConnection
offers OpenShellAsync and nothing else — so every switch on that screen would be a control with no
effect and every counter a number with no source.
rules with toggles and byte counters. Forwarding is built and runs here as on the desktop — a host's
forwards start with every terminal opened to it — but it is not a destination, so it has no row. A
forward belongs to a host and runs with a session, and on this head the session's forwards are a
sheet off the terminal's bar: the ⇄ button, drawn for a tab that has some, lists them with a switch
each. See MainWindowViewModel.IsForwardsSheetOpen.
What the phone lacks is the editor: its host form has no PORT FORWARDS section yet, so forwards are
added on the desktop and arrive here with the host. The byte counters are not built anywhere;
SSH.NET keeps no count to draw.
It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail:
a destination that is quietly absent reads as a product that never intended it, and this one is
intended. SyncEntityType.PortForward = 9 is reserved on the wire and unused. See
docs/design-import-gaps.md.
a place somebody looks for a feature should say where it went. See docs/design-import-gaps.md.
-->
<Border Classes="card" Margin="6,16,6,0">
<StackPanel Spacing="7">
@@ -206,11 +210,11 @@
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource TextDim}" Text="Port forwarding" />
<Border Classes="tag outline" VerticalAlignment="Center">
<TextBlock Text="NOT BUILT" />
<TextBlock Text="IN THE TERMINAL" />
</Border>
</StackPanel>
<TextBlock Classes="body"
Text="A connection here opens one channel — a shell — and nothing else, so there is no tunnel for a rule to run through. The wire protocol reserves a slot for forwarding rules and no client writes one. Nothing on this screen is hidden by a setting; the feature is absent." />
Text="Forwards saved on a host start with every terminal opened to it, here as on the desktop. The ⇄ button in the terminal's bar lists them, with a switch each and the reason one could not start. They are added and edited in the desktop app's host editor; this phone runs them but has no editor for them yet." />
</StackPanel>
</Border>
@@ -36,6 +36,18 @@
The .railentry styles live in this file because they are this control's own shape and nothing else wears
them; every colour in them is resolved from Theme/Palette.axaml by key, which is the rule that matters —
a rail that named its own blues is how a fifth surface colour ends up in the application.
── DEEP CHROME, SINCE v5 ───────────────────────────────────────────────────────────────────────────────
This rail's own surface moves from Sidebar to DeepChrome in this pass, matching the desktop's NavRail —
which is exactly what this control is standing in for on a wide phone, and which took the same v5b move
itself: Palette.axaml's own remark on DeepChrome names "the rail down the side" as one of the five things
that colour is for. Sidebar stays what a card sits on; this is the frame the cards sit inside, the same
distinction PhoneShell's header, strip and bottom bar are drawing at the same time.
The hover fill moves too, from Panel to Track — and Track rather than Hover, because Palette.axaml's own
remark on Track names "the rail row a pointer sits over" as one of the three things that colour is for,
literally. Panel was never named for this; it read close enough that nobody had gone looking for the
seam, which is exactly the kind of gap Track exists to close.
-->
<UserControl.Styles>
@@ -54,7 +66,7 @@
</Style>
<Style Selector="Button.railentry:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Panel}" />
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="Button.railentry.active /template/ ContentPresenter">
@@ -96,7 +108,7 @@
</Style>
</UserControl.Styles>
<Border Width="188" Background="{StaticResource Sidebar}"
<Border Width="188" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<!--
@@ -134,15 +146,17 @@
<!--
The terminal is a surface rather than a page, so it goes through its own command — the same
reason the bottom bar's middle entry does. It is never lit, and deliberately: this rail is
collapsed while a shell is showing, so IsCurrent could only ever read false. What marks that
destination as current is the surface filling the screen.
reason the bottom bar's middle entry does. It lights on IsTerminalSurface, which on this control
can only mean one thing: the rail stands down for a shell, so the one moment it is drawn beside
this surface is Connections with nothing running. That screen is a page in everything but which
enum it is in, and a rail sitting beside it with no entry lit would be pointing nowhere.
No count, unlike the two above it. The shells strip sits above the bottom of every page on this
surface and is that count, drawn as the sessions themselves — a number beside this word would be
the same fact said twice, three inches apart.
-->
<Button Classes="railentry" Command="{Binding ShowTerminalCommand}">
<Button Classes="railentry" Classes.active="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
@@ -25,8 +25,9 @@
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
bottom bar itself. All three are bound on IsShowingPages, which is the same question asked once — the
surface is either a page or a terminal, and these are the chrome a page has.
bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which
is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome.
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
@@ -96,9 +97,18 @@
the surface. See PhoneShell.ShowsVaultHeader.
-->
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
<!--
◆ DeepChrome rather than Chrome, since v5. The desktop's own titlebar and nav rail made the same
move in v5b — one step darker than Chrome, #0B0B14 against #10111E, so the frame reads as what
holds the glass rather than as another pane of it — and this header is the phone's equivalent
furniture: it is what the desktop's titlebar is, on the surface that has no window to carry one.
HostActionBar, which takes this header's own place while hosts are selected, and the editor's
header in HostsScreen, which the header stands down for, both move with it for the same reason;
see the remark on each.
-->
<Border Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<!--
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
@@ -127,30 +137,18 @@
</StackPanel>
<!--
◆ THE PENCIL, and it is here rather than on the row for the reason the whole gesture split
happened: choosing a host on this head no longer raises a bar carrying EDIT, because that bar
was five controls over the bottom of the list in the way of a tap that means "connect". EDIT is
the one of the five common enough to be worth a control that is always in the same place, so it
is in the header — the phone's only piece of persistent chrome — and the long press still
reaches the other four. See HostsScreen.axaml.
◆ THE PENCIL WAS HERE, AND IT MOVED INTO THE ACTION BAR.
Two conditions, nested rather than combined, because Avalonia's bindings have no "and" and the
two belong to different view models: which screen is showing is the shell's question, and
whether there is a host to edit is the vault's. That is the same arrangement the header itself
is wrapped in one level up.
It was in the header because a long press raised a connect card carrying EDIT, and a card over
the bottom of the list was the wrong place for the one action people reach for most. Both
halves of that changed at once: a long press now chooses hosts rather than raising a card, and
the bar it raises takes this header's place — so the pencil is in that bar, beside the count of
what it would edit. See HostActionBar.axaml, which is drawn in the Panel below this one.
Collapsed rather than disabled when there is nothing chosen. A greyed pencil sitting beside the
vault's name on every screen would be a permanent reminder of a control that is only ever about
one row.
What is left in this row is the vault's name, the sync light and LOCK: three facts about the
keychain, and none of them about a row.
-->
<Panel Grid.Column="3" IsVisible="{Binding IsHostsShowing}">
<Button Classes="icon" Content="✎" FontSize="15" Margin="4,0,0,0"
IsVisible="{Binding Vault.CanEditSelectedHost}"
Command="{Binding Vault.EditSelectedHostCommand}"
ToolTip.Tip="Edit the selected host" />
</Panel>
<Button Grid.Column="4" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
ToolTip.Tip="Lock the keychain">
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
Foreground="{StaticResource TextDim}" />
@@ -159,6 +157,21 @@
</Border>
</Panel>
<!-- ============ ◆ the contextual action bar ============ -->
<!--
In the header's row and in its place, never beside it. A bar that stacked under the vault's name
would be two rows of chrome above a list at 360dp, and worse, it would leave the keychain's name and
LOCK on screen over a list that is no longer about the keychain — see PhoneShell.ShowsHostSelectionBar,
which is where the two are made exclusive.
Wrapped so the data context can be the vault, as every other screen in this file is: what the bar
counts and what its four controls do are one vault's business, and the shell's own binding would
resolve none of them.
-->
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsHostSelectionBar}">
<views:HostActionBar DataContext="{Binding Vault}" />
</Panel>
<!-- The screens. Only one draws; which one is the shell's business. -->
<Panel Grid.Row="1">
<!--
@@ -252,6 +265,12 @@
◆ The two host-key decisions, over everything. Outside the screen Panel deliberately: file transfer
makes its own trust decision on the desktop, and the phone now has that screen — so a sheet nested
inside the hosts screen would be one the files screen could not raise.
◆ And that arrangement is what the shell now relies on rather than merely permits. It used to move to
HOSTS before letting a handshake ask the question, because the desktop head drew it as a banner on that
screen; it does not any more, so this sheet is what is over the Connections surface when a machine
typed into the connect box by hand turns out to be a first contact. Which is the case the navigation
was worst for — it took the box away. See MainWindowViewModel.OnVaultConnectionFailed.
-->
<Panel Grid.Row="1">
<views:HostKeySheet DataContext="{Binding Vault}" />
@@ -271,9 +290,17 @@
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
whether there are any tabs and this one is about which surface is up.
◆ And it stands down under the host editor, which is a page rather than a card since the pencil moved
into the action bar. A strip of open shells above a form is the same two-rows-of-chrome problem the
terminal has, on a screen where the form is the whole point of being there. See
PhoneShell.ShowsShellStrip, which is where that "and" is made, Avalonia's bindings having none.
-->
<Panel Grid.Row="2" IsVisible="{Binding IsShowingPages}">
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
<Panel Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsShellStrip}">
<!-- DeepChrome rather than Sidebar, since v5, joining the header and the bar above and below it:
the strip is chrome the same way they are — a frame around the screen rather than a pane of
it — and Sidebar is what a card sits on, which this row is not. -->
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
@@ -282,7 +309,7 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="9"
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="10"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
@@ -293,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>
@@ -308,10 +341,15 @@
<!-- ============ navigation ============ -->
<!--
Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's three
Gone while a shell is showing, which is the whole of that surface's arrangement: the bar's three
destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's
own bar. See TerminalScreen.axaml.
◆ A shell rather than the surface, which are not the same question — Connections with nothing running
is that surface drawing a page, and the bar stays under it. The screen it stands down for is the one
that pays for the room; a box asking which machine to connect to does not, and taking the nav away
from somebody who has just closed their last tab left them with only the back gesture.
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
@@ -321,8 +359,10 @@
shape of everything else already behind the hub. What is left is the two halves of using this
application, and the drawer holding the rest.
-->
<!-- DeepChrome rather than Chrome, since v5 — see the remark on the vault header, above, which this
bar is the foot of the same frame the header is the top of. -->
<Border Grid.Row="3" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Height="64">
<Grid ColumnDefinitions="*,*,*">
@@ -340,12 +380,15 @@
rather than the door. The enum member stays ShellSurface.Terminal, for the reason the keychain's
tab is not called Vault: the surface is a terminal, and the word a user reads is the product's.
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
would be a rule about a state this control cannot be in. What marks it as current is the surface
filling the screen.
◆ It lights again, and IsTerminalSurface is the right question rather than a near miss. This bar
is drawn in exactly two situations — a page, and Connections with nothing running — and that flag
separates them: false for every page, true for the one screen where this is where you are. With a
shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was
collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the
bar can now sit under and not point at is the entry looking broken instead.
-->
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
IsCurrent="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}" />
<!--
@@ -17,6 +17,18 @@ internal sealed partial class PhoneShell : UserControl
{
private MainWindowViewModel? shell;
/// <summary>
/// The open vault, while there is one, so that this control hears about the hosts screen's own state.
/// </summary>
/// <remarks>
/// ◆ <b>A second subscription, and it is the price of the header being swappable.</b> Two of the flags
/// below are questions about the vault rather than about the shell — whether hosts are ticked, and
/// whether the host editor is filling the screen — and the shell does not forward the vault's
/// notifications. Kept in step from <see cref="OnShellChanged"/>, because <c>Vault</c> is replaced on
/// every unlock and nulled on every lock; a handler left on a disposed vault would keep it alive.
/// </remarks>
private VaultViewModel? vault;
/// <summary>
/// Everything the phone draws, which is the element the software keyboard is kept off.
/// </summary>
@@ -90,10 +102,40 @@ internal sealed partial class PhoneShell : UserControl
TryOfferDeviceUnlock();
}
FollowTheVault();
RefreshChrome();
};
}
/// <summary>Moves this control's second subscription onto whichever vault is open now.</summary>
/// <remarks>
/// Compared before being swapped, so that the ordinary case — a shell notification about something else
/// entirely — costs one reference comparison rather than an unsubscribe and a resubscribe per property
/// change on the shell.
/// </remarks>
private void FollowTheVault()
{
if (ReferenceEquals(vault, shell?.Vault))
{
return;
}
if (vault is not null)
{
vault.PropertyChanged -= OnVaultChanged;
}
vault = shell?.Vault;
if (vault is not null)
{
vault.PropertyChanged += OnVaultChanged;
}
}
private void OnVaultChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
=> RefreshChrome();
/// <summary>Whether this surface is wide enough to be laid out like the desktop.</summary>
/// <remarks>
/// A property of the control rather than of the view model, because it is a fact about the surface and
@@ -104,10 +146,19 @@ internal sealed partial class PhoneShell : UserControl
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
/// <summary>Whether the rail down the left edge is drawn.</summary>
/// <remarks>
/// The wide surface's answer to <see cref="ShowsBottomBarProperty"/> and asks the same question about
/// which screen is up, so the two move together — including over Connections with nothing running. See
/// <see cref="RefreshChrome"/>.
/// </remarks>
public static readonly StyledProperty<bool> ShowsRailProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
/// <remarks>
/// Not simply the pages: it also stays up on Connections with nothing running, which is the terminal
/// surface drawing a page. See <see cref="RefreshChrome"/>.
/// </remarks>
public static readonly StyledProperty<bool> ShowsBottomBarProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
@@ -115,6 +166,14 @@ internal sealed partial class PhoneShell : UserControl
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultHeader));
/// <summary>Whether the bar about the chosen hosts is drawn in the header's place.</summary>
public static readonly StyledProperty<bool> ShowsHostSelectionBarProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsHostSelectionBar));
/// <summary>Whether the strip of open shells above the bottom bar is drawn.</summary>
public static readonly StyledProperty<bool> ShowsShellStripProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsShellStrip));
/// <inheritdoc cref="IsWideProperty" />
public bool IsWide
{
@@ -143,17 +202,40 @@ internal sealed partial class PhoneShell : UserControl
private set => SetValue(ShowsVaultHeaderProperty, value);
}
/// <inheritdoc cref="ShowsHostSelectionBarProperty" />
public bool ShowsHostSelectionBar
{
get => GetValue(ShowsHostSelectionBarProperty);
private set => SetValue(ShowsHostSelectionBarProperty, value);
}
/// <inheritdoc cref="ShowsShellStripProperty" />
public bool ShowsShellStrip
{
get => GetValue(ShowsShellStripProperty);
private set => SetValue(ShowsShellStripProperty, value);
}
/// <summary>
/// Works out which chrome this surface should be wearing.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Three flags computed here rather than three conditions in the markup, because Avalonia's
/// ◆ <b>Five flags computed here rather than five conditions in the markup, because Avalonia's
/// bindings have no "and" and none of these is a single question any more.</b> Everywhere else on this
/// head that costs a wrapper element; here it would cost two nested ones per row and the header's would
/// have to be an "or", which a wrapper cannot express at all.
/// </para>
/// <para>
/// ◆ <b>The nav stands down for a shell rather than for the terminal surface, and those parted company
/// when that surface gained a page.</b> Connections with nothing running is a box, a CONNECT button and
/// the machines connected to before — see TerminalScreen.axaml — and none of that is worth the screen a
/// shell is worth it for. It is also the one screen somebody can arrive at by closing their last tab,
/// which made the collapsed bar a way to end up with no route to Hosts or Settings but the system back
/// gesture. The header is deliberately not part of this: the surface draws its own bar with the back
/// arrow and the +, and the vault header above that is the second row this head exists to avoid.
/// </para>
/// <para>
/// <b>The header is the one worth reading twice.</b> Narrow, it stands down behind SETTINGS, because the
/// screens under that hub draw their own header with a back arrow and two rows of chrome is what this
/// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail
@@ -161,7 +243,16 @@ internal sealed partial class PhoneShell : UserControl
/// Losing them on the keychain would be losing the only LOCK button on the surface.
/// </para>
/// <para>
/// Recomputed on every shell notification rather than on a named list of them. Three boolean
/// ◆ <b>The header is now a swap rather than a switch, and the editor takes the whole screen.</b> Two
/// more flags and two more inputs, both of them the vault's rather than the shell's — see
/// <see cref="vault"/>. While hosts are ticked the header stands down and
/// <see cref="ShowsHostSelectionBar"/> puts the action bar in its place, which is what makes that bar
/// unambiguous: the screen is about the ticked hosts and nothing else. While the host editor is open it
/// is a page rather than a card, so all four rows of chrome stand down and the form has the display —
/// which is what "opens in a separate page" means on a 360dp screen.
/// </para>
/// <para>
/// Recomputed on every notification from either object rather than on a named list of them. Five boolean
/// comparisons and no allocation is cheaper than being wrong: the properties this reads are computed
/// ones, and which of them raise a change is a fact about a file in another project that nothing here
/// would notice going stale.
@@ -172,6 +263,24 @@ internal sealed partial class PhoneShell : UserControl
var wide = body.Bounds.Width >= WideAt;
var pages = shell?.IsShowingPages == true;
// ◆ Connections with nothing running, which is the terminal surface drawing a page: a box, a CONNECT
// button and the machines connected to before. The nav stands down for a shell — the whole of the
// arrangement below — and there is no shell here to stand down for, so it stays. Taking it away on
// this one screen was worst where it was least affordable: somebody who has just closed their last
// tab, or who pressed Connections to see what was open and found nothing, was left on a screen whose
// only way to Hosts or Settings was the system back gesture.
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
// The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
// not the shells strip, and not the way off the screen. Its own header carries the back arrow, which
// is the one control it needs and the one the system gesture already maps to.
var editing = vault?.IsEditing == true;
// Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by
// navigating — and a bar counting hosts over the transfers screen would be chrome about a list that
// is not on the display.
var choosing = vault?.IsChoosingHosts == true && shell?.IsHostsShowing == true;
// Before the flags, because it changes what one of them reads. Nothing else on this head navigates
// in response to a resize, and this is not navigation for its own sake: the hub is a list of the
// destinations the rail now carries, so an unfolded device would otherwise sit on a menu of things
@@ -183,9 +292,11 @@ internal sealed partial class PhoneShell : UserControl
}
IsWide = wide;
ShowsRail = wide && pages;
ShowsBottomBar = !wide && pages;
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
ShowsRail = wide && (pages || connectPage) && !editing;
ShowsBottomBar = !wide && (pages || connectPage) && !editing;
ShowsShellStrip = pages && !editing;
ShowsHostSelectionBar = pages && choosing && !editing;
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
}
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -195,6 +306,7 @@ internal sealed partial class PhoneShell : UserControl
return;
}
FollowTheVault();
RefreshChrome();
if (e.PropertyName is nameof(MainWindowViewModel.State))
@@ -279,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;
@@ -298,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);
@@ -426,7 +579,7 @@ internal sealed partial class PhoneShell : UserControl
/// <para>
/// <b>The connect menu is a second such guard, and it matters more than the first.</b> A terminal now
/// fills the screen — no header, no bottom bar — so while that menu is up this gesture is the only way
/// off it other than the scrim and CANCEL. It is checked before the terminal is dismissed for the
/// off it other than the scrim and CANCEL. The forwards sheet, raised from the same bar, shares the guard. It is checked before the terminal is dismissed for the
/// reason it is drawn over it: back takes the topmost thing, and dismissing the surface underneath a
/// menu would take two, neither of them the one being looked at.
/// </para>
@@ -445,13 +598,12 @@ internal sealed partial class PhoneShell : UserControl
return;
}
// The connect menu, which is raised from the terminal's own bar and is the topmost thing the phone
// draws while it is up. Ahead of the editors below because it is nearer, and ahead of leaving the
// terminal because a gesture that dismissed the surface underneath a menu would close two things at
// once — and the one the user was looking at would not be either of them.
if (current.IsConnectSheetOpen)
// The connect menu or the forwards sheet, which are raised from the terminal's own bar and are the
// topmost thing the phone draws while one is up. Ahead of the editors below because they are nearer,
// and ahead of leaving the terminal because a gesture that dismissed the surface underneath a sheet
// would close two things at once — and the one the user was looking at would not be either of them.
if (TryCloseATerminalSheet(current))
{
current.CloseConnectSheetCommand.Execute(null);
e.Handled = true;
return;
}
@@ -494,22 +646,54 @@ internal sealed partial class PhoneShell : UserControl
}
}
/// <summary>
/// Lowers the connect menu or the forwards sheet, whichever is up over the terminal.
/// </summary>
/// <returns>Whether one was closed, and so whether back has been spent.</returns>
/// <remarks>
/// Both are raised from the terminal's bar and the scrim under each covers that bar, so at most one is
/// ever open; the order below decides nothing.
/// </remarks>
private static bool TryCloseATerminalSheet(MainWindowViewModel current)
{
if (current.IsConnectSheetOpen)
{
current.CloseConnectSheetCommand.Execute(null);
return true;
}
if (current.IsForwardsSheetOpen)
{
current.CloseForwardsSheetCommand.Execute(null);
return true;
}
return false;
}
/// <summary>
/// Lowers whatever the hosts screen has raised over its list, topmost first.
/// </summary>
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
/// <remarks>
/// <para>
/// Order is the whole of it. The two sheets sit over the list and the two editors sit in place of it, so
/// a sheet has to go first — closing an editor while a sheet was open would leave the sheet floating
/// over a list nobody asked to see, and the second back would then close the sheet rather than the
/// editor the user was looking at.
/// Order is the whole of it. The sheets sit over the list, the panels sit above it and the editors sit
/// in place of it, so a sheet has to go first — closing an editor while a sheet was open would leave the
/// sheet floating over a list nobody asked to see, and the second back would then close the sheet rather
/// than the editor the user was looking at.
/// </para>
/// <para>
/// The editors are cancelled rather than merely hidden. Cancelling is what clears the boxes, and the
/// host editor's boxes are the ones worth clearing: leaving a half-typed hostname behind would have the
/// next NEW HOST open on somebody else's abandoned draft.
/// </para>
/// <para>
/// ◆ <b>Selection mode is last and is still a thing back has to spend itself on.</b> It is a mode rather
/// than a surface — the list underneath is fully drawn and the only sign of it is the bar across the top
/// — and a gesture that left the application from it would take somebody out of the app because they had
/// held a row down. Its panels go before it, in the order they are stacked: the picker or the question is
/// what the user is looking at, and the ticks underneath are what it is about.
/// </para>
/// </remarks>
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
{
@@ -518,22 +702,71 @@ internal sealed partial class PhoneShell : UserControl
return false;
}
return TryLowerASheet(vault) || TryCloseSomethingBehindTheSheets(vault);
}
/// <summary>Lowers the nearest of the four sheets, which are what sits over everything else.</summary>
/// <remarks>
/// The four cannot be open at once — each is raised from a control the others hide — so their order
/// between themselves decides nothing. What matters is that all of them come before the panels and the
/// editors underneath: closing an editor while a sheet was open would leave the sheet floating over a
/// list nobody asked to see.
/// </remarks>
private static bool TryLowerASheet(VaultViewModel vault)
{
// ◆ The action bar's own menu, first of the four because it is raised from chrome that is already
// over everything else.
if (vault.IsHostActionSheetOpen)
{
vault.CloseHostActionSheetCommand.Execute(null);
return true;
}
// The password sheet, which is what a tap on a machine that wants one raises. Cancelled rather than
// hidden, because cancelling is what empties the box — see VaultViewModel.CancelConnectPassword.
if (vault.IsAskingForConnectPassword)
{
vault.CancelConnectPasswordCommand.Execute(null);
return true;
}
if (vault.IsAddSheetOpen)
{
vault.CloseAddSheetCommand.Execute(null);
return true;
}
// The other sheet, and it is checked beside the first rather than after the editors for the same
// reason: it is raised over the list, so it is the nearest thing on screen. The two cannot be open
// at once — one is raised by the +, the other by a heading, and each hides the list the other's
// control is on — so their order between themselves decides nothing.
if (vault.GroupSheet is not null)
{
vault.CloseGroupSheetCommand.Execute(null);
return true;
}
return false;
}
/// <summary>Closes the nearest of the panels, the editors and selection mode itself.</summary>
/// <inheritdoc cref="TryCloseAnOpenEditor" path="/remarks" />
private static bool TryCloseSomethingBehindTheSheets(VaultViewModel vault)
{
if (vault.IsSendingChosenHostsToAVault)
{
vault.CancelSendChosenHostsToAVaultCommand.Execute(null);
return true;
}
if (vault.IsRegroupingChosenHosts)
{
vault.CancelRegroupChosenHostsCommand.Execute(null);
return true;
}
if (vault.IsConfirmingChosenHostDeletion)
{
vault.CancelDeleteCommand.Execute(null);
return true;
}
if (vault.IsEditing)
{
vault.CancelEditCommand.Execute(null);
@@ -546,6 +779,12 @@ internal sealed partial class PhoneShell : UserControl
return true;
}
if (vault.IsChoosingHosts)
{
vault.ClearHostChoiceCommand.Execute(null);
return true;
}
return false;
}
@@ -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>
@@ -36,8 +36,16 @@
good way to read a code and a bad way to copy one: a user who cannot select it will photograph the
screen, and that is a worse home for it than their clipboard.
-->
<!--
◆ CornerRadius 12, not 6. Its padding, 14,13, is Border.card's own — this box was already drawn to a
card's proportions, in every dimension but the corner — so under v2's ladder 6 was an outlier this
file never explained. It is worse than unexplained now: v5's ladder gives 6 to a chip, and a
full-width box holding the one secret this whole screen exists to show is not a chip wearing a
card's padding by coincidence. 12 is what a card-shaped box gets on this ladder, and this one always
was one.
-->
<Border Margin="0,20,0,0" Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="6" Padding="14,13">
BorderThickness="1" CornerRadius="12" Padding="14,13">
<SelectableTextBlock Text="{Binding RecoveryCode}"
FontFamily="{StaticResource MonoFont}" FontSize="14"
Foreground="{StaticResource Accent}"
@@ -20,9 +20,17 @@
<StackPanel Spacing="10" HorizontalAlignment="Center" Margin="0,48,0,36">
<!--
Filled rather than outlined since v2, the same mark the header, the desktop titlebar and the
launcher icon carry. The radius is not picked: the mark runs 6 at 20 and 8 at 26, which is a
third of a unit per unit of tile and lands on 14 at 44 — and 14 is a rung of Phone.axaml's
ladder, the one for a block of monospaced output, which is what this is.
launcher icon carry. The radius is not picked: the mark runs 6 at 20 and 8 at 26, a third of a
unit per unit of tile, and lands on 14 at 44.
◆ That used to be a rung of Phone.axaml's own ladder too — 14, the one for a block of monospaced
output, which this glyph reads as — and the coincidence was worth a sentence: one fewer number
the ladder had to introduce. It is a coincidence no longer. v5's ladder moves output to 12 and
never reaches 14 at all, so the mark's own 6/8/14 progression stands on its own now rather than
borrowing a card- or output-shaped justification — a self-contained proportion for a badge that
answers to nothing but its own three sizes. Left at 14 rather than moved to 12 with everything
else: the ladder governs content surfaces, and this is a mark, sized off its own tile rather than
off what it holds.
-->
<Border Width="44" Height="44" CornerRadius="14" Background="{StaticResource Accent}"
HorizontalAlignment="Center">
@@ -64,6 +64,26 @@
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
◆ Which vault a *new* snippet is filed into. Hidden for an existing one — its vault is not a
field of this form, and changing it is MOVE below — and hidden entirely where there is only one
vault to choose between, which is where most people stay.
-->
<StackPanel Spacing="6" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding EditorVaultChoices}"
SelectedItem="{Binding EditorSelectedVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
</StackPanel>
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top" Padding="14,10" />
@@ -125,10 +145,23 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" />
</Border>
<!--
◆ Which vault this one is in, drawn only where there is more than one to be in. A shared
snippet is a command the rest of a team can read and insert into their own terminals,
and this card is the only place that fact appears before somebody taps EDIT.
-->
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
</StackPanel>
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
<Border Classes="output" CornerRadius="9" Padding="11,9">
<!-- The command, on the surface every block of monospace in this design is drawn on.
No radius override any more: it diverged from Border.output's own 14 under v2 for a
smaller card-nested block, and now that the style's own value has moved to 12 there is
no gap left worth a second number for — it takes the class's, like every other output
block on this head. -->
<Border Classes="output" Padding="11,9">
<TextBlock Classes="mono" FontSize="11" Text="{Binding Preview}"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
</Border>
@@ -145,12 +178,17 @@
second column to put it in. Both buttons name the terminal — "TYPE INTO pg-primary" — because on a
phone the tab strip may be scrolled away and "whatever is in the terminal receives this" is not a
sentence anybody should have to guess the subject of.
DeepChrome rather than Chrome, since v5: this bar is chrome furniture in the same sense the bottom
nav bar it sits above is — a raised strip of frame rather than a pane of content — and joins the same
DeepChrome decision PhoneShell's header, strip and bar made. See the remark there.
-->
<Border Grid.Row="4" IsVisible="{Binding HasSelection}" Background="{StaticResource Chrome}"
<Border Grid.Row="4" IsVisible="{Binding HasSelection}" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="14,12"
IsEnabled="{Binding !IsEditing}">
<StackPanel Spacing="9">
<StackPanel Spacing="9" IsVisible="{Binding ShowsSelectionActions}">
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
IsEnabled="{Binding CanInsert}" />
@@ -162,9 +200,54 @@
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditCommand}" />
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
Command="{Binding DeleteCommand}" />
<!--
◆ Sharing the snippet. Beside EDIT rather than inside it, which is the line the hosts screen
draws too: the two vaults are encrypted under different keys, so this is a re-seal into one and
a tombstone in the other — nothing a SAVE could do. It shows only where there is somewhere to
move to; see SnippetsViewModel.CanMove.
-->
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
IsVisible="{Binding CanMove}" Command="{Binding MoveCommand}" />
</Grid>
<!--
◆ A row of its own, under the pair rather than beside them, as the hosts screen puts it. A phone
has no hover and no tooltip, so the only thing separating a destructive button from an ordinary
one is where a thumb lands.
-->
<Button Classes="danger" Height="44" Content="DELETE" Command="{Binding DeleteCommand}" />
</StackPanel>
<!--
◆ MOVING THE SNIPPET TO ANOTHER VAULT, in the place the insert controls were. A picker and two
buttons rather than a question with a yes: what is being asked is which vault, and a move is undone
by moving it back.
The sentence is not decoration. Unlike a host, a snippet crosses whole — there is no group and no
tag to leave behind — so what there is to say is who can read it afterwards; and on a phone, where
the status line is one line at the bottom of a screen somebody has already navigated away from,
before the tap is the only place that reliably gets read.
-->
<StackPanel Spacing="10" IsVisible="{Binding IsMoving}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveCommand}" />
</Grid>
</StackPanel>
</StackPanel>
</Border>
@@ -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();
}
}
@@ -19,9 +19,13 @@
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
four-entry bottom bar are all collapsed by PhoneShell while this surface is up, and what replaces them
three-entry bottom bar are all collapsed by PhoneShell while a shell is showing, and what replaces them
is the row below: back, the sessions, and the way to open another one.
With nothing running it is only the header and the strip. This surface is then the connect page below —
a box and the machines connected to before — which is not what the screen was being cleared for, and is
the one screen reachable by closing your last tab, so the nav stays. See PhoneShell.RefreshChrome.
That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this
screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every
one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap
@@ -29,9 +33,122 @@
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>
<!--
── the forwards ──────────────────────────────────────────────────────────────────────────────────────
The desktop sidebar's state word, in the same three colours: green only while listening, the danger
text colour for a forward that could not start, faint for OFF and STARTING. The bar's ⇄ follows the
same rule for the failed case and is otherwise the colour of the icons beside it. Mono and 9.5, which
is this head's label lettering rather than the desktop's Montserrat, and its own family for the reason
the step captions above give.
-->
<Style Selector="TextBlock.fwdstate">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="9.5" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.fwdstate.live">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="TextBlock.fwdstate.failed">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Style>
<Style Selector="TextBlock.fwdglyph">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="TextBlock.fwdglyph.failed">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</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 ============ -->
<!--
@@ -45,9 +162,12 @@
to 34, the pills to 30 — because a bar that shrank around controls that did not would only have moved
the clipping somewhere harder to see.
-->
<Border Grid.Row="0" Height="35" Background="{StaticResource Chrome}"
<!-- DeepChrome rather than Chrome, since v5: this bar is what the vault header, the strip and the
bottom bar collapse into while a shell is showing, so it takes their surface along with their
job — see the remark on the header in PhoneShell.axaml. -->
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<!--
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
@@ -83,15 +203,21 @@
row where nothing sits above or below it to be hit by mistake.
-->
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="9" Height="30">
BorderThickness="1" CornerRadius="10" Height="30">
<StackPanel Orientation="Horizontal">
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="9"
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="10"
VerticalContentAlignment="Center"
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}" />
@@ -112,7 +238,7 @@
It reads as a misprint, which for the control that ends a session is the wrong thing
to look like.
-->
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,9,9,0"
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,10,10,0"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
@@ -146,10 +272,37 @@
HOSTS, which is the only action on its screen; this one is not.
Click as well as Command, and the handler does one thing the command cannot: put the software
keyboard away. See OnConnectMenuRequested.
keyboard away. See OnSheetRequested.
-->
<Button Grid.Column="2" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
Click="OnConnectMenuRequested"
<!--
The selected tab's forwards, which is what lets this head start them at all: a phone opening
listeners it never shows you is worse than one opening none. Drawn only for a tab that has some, so
a host without forwards pays nothing for the feature in a 35-pixel bar.
The count beside the arrows is the listening ones, in the green that means "running" everywhere
else; the arrows themselves turn the danger colour when one could not start. The desktop can leave a
refusal in its sidebar for the eye to find, because the sidebar is always drawn. The sheet this opens
is not, so the button has to say it first.
Outside the ScrollViewer with +, for the reason both ends are: a tenth tab must not push it away.
Width is not pinned to Button.icon's 44, because "⇄ 12" is wider than that; 44 is its minimum.
-->
<Button Grid.Column="2" Classes="icon" Height="34" Width="NaN" MinWidth="44" Padding="8,0"
Click="OnSheetRequested"
Command="{Binding OpenForwardsSheetCommand}"
IsVisible="{Binding SelectedTab.HasForwards, FallbackValue=False}">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<TextBlock Classes="fwdglyph" Classes.failed="{Binding SelectedTab.HasFailedForward, FallbackValue=False}"
Text="⇄" FontSize="16" VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource Live}"
VerticalAlignment="Center"
Text="{Binding SelectedTab.ListeningForwardCount, FallbackValue=''}"
IsVisible="{Binding SelectedTab.HasListeningForward, FallbackValue=False}" />
</StackPanel>
</Button>
<Button Grid.Column="3" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
Click="OnSheetRequested"
Command="{Binding OpenConnectSheetCommand}" />
</Grid>
</Border>
@@ -243,7 +396,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}" />
@@ -277,11 +430,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"
<!--
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>
<!--
@@ -302,6 +526,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 ============ -->
@@ -313,70 +560,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"
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" />
</ScrollViewer>
<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.
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.
-->
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
<!--
The cursor keys, and the two text-size buttons riding at the end.
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. 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.
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.
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.
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.
-->
<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"
<!-- 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="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" Focusable="False"
<Button Classes="hotkey" Width="44" Focusable="False"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A−" />
</Button>
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" Focusable="False"
<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>
@@ -389,9 +730,10 @@
of them.
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
but this surface raises it. The scrim reaching only the screen area is not a compromise here the way
it was there — the bottom bar is collapsed while a terminal is showing, so the screen area is the
display.
but this surface raises it. The scrim reaching only the screen area is the full display while a shell
is showing, because the bottom bar is collapsed for it. Raised from the connect page it is not, and
that lands on the add sheet's own answer: the three destinations under the scrim stay tappable, and for
a menu of places to connect from, navigating away is a perfectly good way to decide not to.
Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu:
each of the three destinations is a picker, and the shell they open lands back on this surface as a
@@ -455,6 +797,75 @@
</Border>
</Panel>
<!-- ============ the forwards sheet ============ -->
<!--
The desktop sidebar's FORWARDS section, as a sheet: the selected tab's forwards as they run over its
session, bound to the same rows. Each row is its own switch, as there — tap a listening one to stop it,
an off or failed one to start it, a failed one being a retry because the port may have been freed.
The route is written under the notation rather than left to a tooltip, which is where the desktop
keeps it: a phone has no hover, and which end each port is on is exactly what people get backwards
about -L and -R. The reason a row failed sits under that again, as the desktop draws it.
The same shape as the connect sheet above, raised from the bar the same way, collapsing the renderer
the same way — see MainWindowViewModel.IsForwardsSheetOpen. It scrolls, capped at most of the display,
because a host may carry more forwards than fit, and a sheet that grew past the top of the screen would
put its own title out of reach.
Nothing here edits a forward, and there is no add row: this head's host editor has no PORT FORWARDS
section yet, and the desktop's Add forward row leads to one. The footer says where they are edited.
-->
<Panel IsVisible="{Binding IsForwardsSheetOpen}">
<Button Classes="scrim" Command="{Binding CloseForwardsSheetCommand}" />
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
CornerRadius="22,22,0,0" Padding="20,18,20,16">
<StackPanel Spacing="0">
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
HorizontalAlignment="Center" Margin="0,0,0,16" />
<TextBlock Classes="title" Text="FORWARDS" FontSize="13" />
<TextBlock Classes="detail" FontSize="10.5" Margin="0,3,0,0"
Text="{Binding SelectedTab.Label, FallbackValue=''}" />
<ScrollViewer Margin="0,10,0,0" MaxHeight="420" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding SelectedTab.Forwards}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PortForwardViewModel">
<Button Classes="row" Padding="12,8" Command="{Binding ToggleCommand}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
Text="{Binding Notation}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Text="{Binding Route}" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding Detail}"
IsVisible="{Binding Detail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="fwdstate" Margin="12,0,0,0"
Text="{Binding StateText}"
Classes.live="{Binding IsListening}" Classes.failed="{Binding IsFailed}" />
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock Classes="body" Margin="0,10,0,0"
Text="Tap a forward to switch it off or on. Local and SOCKS forwards listen on this phone's localhost, where any app on it can connect, and every forward stops with this tab's connection. Forwards are saved on the host and edited in the desktop app; a change starts with the next connection." />
<Button Classes="secondary" Content="DONE" Margin="0,12,0,0"
Command="{Binding CloseForwardsSheetCommand}" />
</StackPanel>
</Border>
</Panel>
</Panel>
</UserControl>
@@ -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,30 +96,127 @@ 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>
/// Puts the keyboard away on the way to the connect menu.
/// 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 or the forwards sheet.
/// </summary>
/// <remarks>
/// <para>
/// One handler for both buttons in the terminal's bar that raise a sheet, since what follows is true of
/// either: each is a panel at the bottom of the screen, which is where the keyboard is.
/// </para>
/// <para>
/// A handler beside the button's command rather than something the command does, because the command is
/// on the shared view model and this is a platform call: the desktop head has no software keyboard to
@@ -100,7 +226,7 @@ internal sealed partial class TerminalScreen : UserControl
/// <para>
/// <b>The keyboard is up in the ordinary case.</b> This button sits above a terminal somebody is typing
/// into, so the sheet it raises would otherwise arrive underneath a keyboard covering half the display —
/// and the three rows on it are the bottom half. Worse, the keyboard's inset shortens everything the
/// and the rows on it are the bottom half. Worse, the keyboard's inset shortens everything the
/// phone draws (see <c>PhoneShell.ApplyKeyboardInset</c>), so the sheet would be laid out into the
/// remaining strip rather than merely hidden behind it.
/// </para>
@@ -109,7 +235,7 @@ internal sealed partial class TerminalScreen : UserControl
/// what the other writes: this one talks to the platform and the command sets a flag, so they commute.
/// </para>
/// </remarks>
private void OnConnectMenuRequested(object? sender, RoutedEventArgs e) => SoftKeyboard.Hide();
private void OnSheetRequested(object? sender, RoutedEventArgs e) => SoftKeyboard.Hide();
/// <summary>
/// Takes the browser gestures back off a surface that is not a web page.
@@ -137,50 +263,28 @@ 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.
foreach (var (label, bytes, latches) in Keys)
{
var key = new Button
{
Content = new TextBlock
{
Text = label,
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
}
// 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,
// ◆ 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.
//
// 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.
Focusable = false,
};
/// <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);
if (latches)
{
@@ -196,6 +300,39 @@ internal sealed partial class TerminalScreen : UserControl
}
}
/// <summary>One key of the accessory row, before its click is wired.</summary>
/// <remarks>Split from <see cref="BuildAccessoryRow"/> for length rather than for reuse.</remarks>
private static Button CreateKey(string label) =>
new()
{
Content = label,
// ◆ 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 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.
//
// 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,
};
private void ToggleControl()
{
controlLatched = !controlLatched;
@@ -207,18 +344,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
@@ -336,9 +336,11 @@
the thing the whole screen is about — a member can only be selected under one, so choosing who is the
only half left to make.
-->
<!-- DeepChrome rather than Chrome, since v5 — the same raised-bar decision SnippetsScreen and
FilesScreen make for their own selection bars; see the remark on PhoneShell's vault header. -->
<Border Grid.Row="4"
IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="14,12">
<StackPanel Spacing="9">
+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"
}
}
+31 -1
View File
@@ -259,6 +259,22 @@ public interface ISyncApi
CancellationToken cancellationToken);
}
/// <summary>Asks the server for leave to relay one connection to one host.</summary>
/// <remarks>
/// The request names a host and nothing else. Where it is, and whether this caller may reach it, is the
/// server's decision, made from its own copy of the host's relay address — which is what keeps the relay
/// from being an open proxy that a client points wherever it likes. See ADR 0004.
/// </remarks>
public interface IRelayApi
{
/// <summary>Issues a single-use ticket, good for thirty seconds, for one relay socket to this host.</summary>
/// <exception cref="DodoSshApiException">
/// The server refused: <c>relay-unavailable</c>, <c>relay-target-rejected</c> or
/// <c>relay-limit-reached</c>, each with its own remedy.
/// </exception>
Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken);
}
/// <summary>
/// The typed client for one DodoSSH server.
/// </summary>
@@ -275,7 +291,7 @@ public interface ISyncApi
/// </para>
/// </remarks>
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi, IRelayApi
{
private const string MetaPath = "/api/v1/meta";
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
@@ -310,6 +326,20 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
DodoSshJsonContext.Default.DodoSshConfiguration,
cancellationToken);
/// <inheritdoc />
/// <remarks>
/// Never cached, and never fetched ahead of need: a ticket is spent by the socket it opens and is
/// worthless thirty seconds after issue, so the only right moment to ask for one is the connect that
/// will use it.
/// </remarks>
public Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
RelayProtocol.TicketsPath,
JsonContent.Create(new RelayTicketRequest(hostId, PortForwardId: null), DodoSshJsonContext.Default.RelayTicketRequest),
DodoSshJsonContext.Default.RelayTicketResponse,
cancellationToken);
/// <summary>
/// Reads the caller's profile, unlock material and reachable vaults.
/// </summary>
File diff suppressed because it is too large. Load diff
+304 -15
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;
@@ -25,6 +26,20 @@ namespace DodoSSH.Client.App;
/// </remarks>
internal sealed partial class DodoSshApp : Application
{
/// <summary>
/// Where a <c>dodossh://</c> link goes once the shell exists; null until then.
/// </summary>
/// <remarks>
/// Links can arrive before there is anything to hand them to — macOS delivers the one an application was
/// opened with as an event shortly after launch, which may be while the splash is still up — so until
/// <see cref="Compose"/> sets this, the latest is kept in <see cref="waitingLink"/>. Both are touched on
/// the UI thread only.
/// </remarks>
private static Action<SpelunkLink>? openLink;
/// <summary>A link that arrived before <see cref="openLink"/> was set. See there.</summary>
private static SpelunkLink? waitingLink;
/// <inheritdoc />
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@@ -33,12 +48,124 @@ internal sealed partial class DodoSshApp : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
Compose(desktop);
ListenForOpenedLinks();
ShowSomethingWhileComposing(desktop);
}
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// Takes the links the platform hands a running application, which on macOS is every link.
/// </summary>
/// <remarks>
/// <para>
/// Windows and Linux start the executable with the link as an argument, and <c>Program.Main</c> reads it
/// there — or passes it to the instance already running. macOS does neither: Launch Services sends the
/// running application, or the one it has just started, an "open URL" event, which Avalonia raises as an
/// activation. The scheme is declared for it in build/macos's Info.plist template.
/// </para>
/// <para>
/// Checked exactly as a launch argument is, by <see cref="SpelunkLink.TryParse"/>; anything else is
/// ignored. Where the lifetime offers no activation at all this does nothing, which is the answer on the
/// desktops that deliver links as arguments instead.
/// </para>
/// </remarks>
private void ListenForOpenedLinks()
{
if (TryGetFeature(typeof(IActivatableLifetime)) is not IActivatableLifetime activatable)
{
return;
}
activatable.Activated += (_, e) =>
{
if (e is ProtocolActivatedEventArgs { Kind: ActivationKind.OpenUri, Uri: { } uri }
&& SpelunkLink.TryParse(uri.OriginalString, out var link))
{
Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link));
}
};
}
/// <summary>Hands a link to the shell, or keeps it until there is one. UI thread only.</summary>
private static void Receive(SpelunkLink link)
{
if (openLink is { } open)
{
open(link);
}
else
{
waitingLink = link;
}
}
/// <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 +199,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
@@ -92,7 +230,10 @@ internal sealed partial class DodoSshApp : Application
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
{
var paths = ClientPaths.Default;
// ForChannel rather than Default, so a nightly keeps its cache, outbox and device key somewhere
// the release build never opens. See ClientPaths.ForChannel for why sharing them is the failure
// worth spending a directory on.
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
@@ -110,14 +251,86 @@ 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.
// A second launch's dodossh:// link comes the same way as this launch's own and macOS's: through
// Receive on the UI thread, then MainWindowViewModel.OpenLink, which holds it until a vault is open.
var others = SingleInstance.Listen(
paths,
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)),
link => Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link)));
openLink = viewModel.OpenLink;
if ((waitingLink ?? Program.LaunchLink) is { } first)
{
waitingLink = null;
viewModel.OpenLink(first);
}
// 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,
@@ -137,18 +350,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 —
@@ -158,7 +441,8 @@ internal sealed partial class DodoSshApp : Application
IClassicDesktopStyleApplicationLifetime desktop,
MainWindowViewModel viewModel,
TerminalWorkspace workspace,
ClientCacheFactory caches)
ClientCacheFactory caches,
IAsyncDisposable others)
{
var shuttingDown = false;
@@ -177,6 +461,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

+157 -15
View File
@@ -1,13 +1,18 @@
# Regenerates dodossh.ico from the same geometry the Android launcher icon draws.
# Regenerates dodossh.ico and dodossh.icns from the same geometry the Android launcher icon draws.
#
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
# reason it is a vector is that there is then one geometry to change and no set of PNG densities
# to forget one of. Windows will not take a vector: <ApplicationIcon> wants an .ico and nothing
# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays
# honest: the numbers below are the ones in that XML, and regenerating is the whole edit.
# to forget one of. Neither desktop platform will take a vector: <ApplicationIcon> wants an .ico
# and nothing else, Window.Icon wants a bitmap, and vpk wants an .icns for the macOS bundle. So
# the rasters exist, and this script is how they stay honest: the numbers below are the ones in
# that XML, and regenerating is the whole edit.
#
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
#
# Both outputs are written every run, deliberately. Two scripts, or one script with a switch,
# is how the two files come to be drawn from different geometry — which nobody would notice,
# because no one person looks at a Windows taskbar and a macOS Dock on the same afternoon.
#
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
@@ -22,37 +27,57 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5B8CFF') # dodo_accent / AccentColor
$ink = [System.Drawing.ColorTranslator]::FromHtml('#0E1220') # AccentInk
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5D42DE') # dodo_accent / AccentColor
$ink = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') # AccentInk
# Every size Windows asks for: 16 in a titlebar and a tree, 32 on the desktop, 48 in a large-icon
# view, 256 for the preview pane. Shipping fewer means Windows downsamples one of these to fill
# the gap, and its downsampler is not kind to a hairline.
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
function New-MarkPng([int]$size)
# $tileFraction is how much of the canvas the accent tile fills, and it is the one number that
# differs between the two platforms.
#
# Windows passes 1.0: the tile bleeds to the edge, because Windows draws application icons at
# whatever size they come in and every other icon on the taskbar does the same.
#
# macOS passes 0.8047, and that is not taste. Apple's icon grid puts a rounded-rect app icon in
# an 824-pixel square inside a 1024-pixel canvas — 824/1024 — with the remaining hundred pixels a
# side left as air for the Dock's shadow and its magnification. An icon that ignores the grid and
# bleeds to the edge does not read as bold; it reads as the one icon in the Dock that is too big,
# because it sits beside Finder and Safari which do not.
function New-MarkPng([int]$size, [double]$tileFraction = 1.0)
{
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [System.Drawing.Graphics]::FromImage($bitmap)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
# The tile, and the inset that centres it when it does not fill the canvas.
$tile = [double]$size * $tileFraction
$inset = ([double]$size - $tile) / 2.0
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
$radius = [double]$size * 0.22
#
# 0.22 of the tile rather than of the canvas, so the corner keeps its proportion to the shape
# it is rounding instead of growing as the air around it does. It is also within a whisker of
# the 185/824 Apple's own grid specifies, which is why one radius serves both files.
$radius = $tile * 0.22
$d = $radius * 2.0
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$path.AddArc(0.0, 0.0, $d, $d, 180, 90)
$path.AddArc($size - $d, 0.0, $d, $d, 270, 90)
$path.AddArc($size - $d, $size - $d, $d, $d, 0, 90)
$path.AddArc(0.0, $size - $d, $d, $d, 90, 90)
$path.AddArc($inset, $inset, $d, $d, 180, 90)
$path.AddArc($inset + $tile - $d, $inset, $d, $d, 270, 90)
$path.AddArc($inset + $tile - $d, $inset + $tile - $d, $d, $d, 0, 90)
$path.AddArc($inset, $inset + $tile - $d, $d, $d, 90, 90)
$path.CloseFigure()
$brush = New-Object System.Drawing.SolidBrush($accent)
$g.FillPath($brush, $path)
# 108-viewport units to pixels, with the outer 18 dropped on each edge.
$scale = [double]$size / 72.0
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) }
# 108-viewport units to pixels, with the outer 18 dropped on each edge. Scaled to the tile and
# offset by the inset, so the glyph keeps its place within the tile at either fraction.
$scale = $tile / 72.0
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale + $inset), (($y - 18.0) * $scale + $inset)) }
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
# difference between a mark and a smudge. The phone's file already bumps this width for the
@@ -117,3 +142,120 @@ $target = Join-Path $PSScriptRoot 'dodossh.ico'
$w.Dispose(); $out.Dispose()
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
# ---- dodossh.icns, for the macOS bundle ----------------------------------------------------------
#
# Written here rather than by `iconutil` on a Mac, and that is the point of doing it the long way.
# iconutil is the documented tool and it exists only on macOS, so an icon that needed it could not
# be regenerated on the machine this project is developed on — the geometry above would change and
# the .icns would quietly keep the old mark until somebody next opened a Mac. The container format
# is a magic word, a length and a run of typed PNG chunks, which is little enough to own.
#
# ◆ EVERY LENGTH IN THIS FILE IS BIG-ENDIAN, AND BinaryWriter IS NOT.
#
# The one thing that will catch anybody editing this. A .icns written little-endian is not rejected
# with an error — Finder and vpk both just show the placeholder icon, because the first chunk claims
# a length of about two billion and the parser walks off the end and gives up. Hence Write-BE32.
#
# Type codes are Apple's, and the pairs are not redundant. ic08 and ic13 are both 256 pixels because
# one is "256 at 1x" and the other is "128 at 2x", and a Retina display asked for the second will not
# accept the first. Same for ic09/ic14 at 512. iconutil emits both from an .iconset for this reason,
# so this does too.
$icnsTypes = @(
@{ Type = 'ic11'; Size = 32 } # 16@2x
@{ Type = 'ic12'; Size = 64 } # 32@2x
@{ Type = 'ic07'; Size = 128 } # 128@1x
@{ Type = 'ic13'; Size = 256 } # 128@2x
@{ Type = 'ic08'; Size = 256 } # 256@1x
@{ Type = 'ic14'; Size = 512 } # 256@2x
@{ Type = 'ic09'; Size = 512 } # 512@1x
@{ Type = 'ic10'; Size = 1024 } # 512@2x
)
# Apple's icon grid: an 824-pixel shape centred in a 1024-pixel canvas. See New-MarkPng.
$macTileFraction = 824.0 / 1024.0
# Rendered once per distinct pixel size rather than once per type code, so the two 256s and the two
# 512s are byte-identical and the file does not carry the same image twice over at different
# compression. It also halves the drawing, which at 1024 is not nothing.
$rendered = @{}
foreach ($size in ($icnsTypes.Size | Sort-Object -Unique))
{
[byte[]]$png = New-MarkPng $size $macTileFraction
$rendered[$size] = $png
}
$icns = New-Object System.IO.MemoryStream
function Write-BE32([System.IO.Stream]$stream, [uint32]$value)
{
$bytes = [System.BitConverter]::GetBytes($value)
if ([System.BitConverter]::IsLittleEndian) { [array]::Reverse($bytes) }
$stream.Write($bytes, 0, 4)
}
function Write-Ascii([System.IO.Stream]$stream, [string]$text)
{
$bytes = [System.Text.Encoding]::ASCII.GetBytes($text)
$stream.Write($bytes, 0, $bytes.Length)
}
# The header's length field covers the whole file including the header, so it is written last —
# eight bytes of nothing now, seeked back to and filled in once the total is known.
Write-Ascii $icns 'icns'
Write-BE32 $icns 0
foreach ($entry in $icnsTypes)
{
$payload = $rendered[$entry.Size]
Write-Ascii $icns $entry.Type
# Length includes this chunk's own eight-byte header, which is the off-by-eight everybody
# writes once.
Write-BE32 $icns ([uint32]($payload.Length + 8))
$icns.Write($payload, 0, $payload.Length)
}
$total = [uint32]$icns.Length
$icns.Position = 4
Write-BE32 $icns $total
$icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
[System.IO.File]::WriteAllBytes($icnsTarget, $icns.ToArray())
$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)"
}
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

@@ -42,6 +42,45 @@
-->
<ApplicationIcon>Assets/dodossh.ico</ApplicationIcon>
<!--
============ WHICH CHANNEL THIS BUILD BELONGS TO ============
The same split the Android head has, for the same reason and with one difference worth stating up
front: Android gets separation for free, because the platform refuses an update signed by a
different key, so its two channels cannot replace one another whatever anybody does. Nothing
refuses anything here. Velopack applies what its feed serves, so the separation has to be built:
two pack ids, two Velopack channels, two feeds, and a profile directory each.
release — DodoSSH.Desktop, Velopack channel win, read from the newest non-prerelease release.
Cut from a v* tag by scripts/release-windows.ps1, by a person. See ADR 0013 rule 3.
nightly — DodoSSH.Desktop.Nightly, Velopack channel win-nightly, read from a prerelease release
CI replaces on every push to main.
Default release, so an unqualified `dotnet build` is the real application and the nightly is the
one you have to ask for.
What this property does *not* decide is the pack id or the title. Those are arguments to vpk and
live where the packaging happens — in ci.yml for the nightly and in the release script for the
release. Putting them here would suggest the build knows which package it will end up inside,
and it does not.
-->
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
<!--
For the macOS keychain interop in Platform/, and for nothing else.
Set on this project rather than in Directory.Build.props deliberately. The frameworks that hold a
Secure Enclave key take CFDictionaries of raw pointers, so building one means pinning arrays and
taking their addresses — see MacDeviceKeyStore. Every other project here is managed code with no
business doing that, and a solution-wide flag would quietly permit it everywhere, including in the
crypto project where a stray pointer is the last thing anybody wants to have been allowed.
The alternative — GCHandle.Alloc with GCHandleType.Pinned — needs no flag and was considered. It
would replace each `fixed` with an allocate/free pair that has to be balanced by hand across the
early returns those methods are full of, which trades a compiler-checked scope for a manual one.
-->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!--
False here, unlike every server project. The root Directory.Build.props sets it true because
the API is container-hosted, UTC-only and has no business formatting anything for a human.
@@ -60,6 +99,16 @@
<AvaloniaResource Include="Assets/dodossh.ico" />
</ItemGroup>
<ItemGroup>
<!--
Which channel this build belongs to, carried in the assembly the same way the Android head carries
it. Metadata rather than a compile-time constant for the reason stated there: the updater needs the
string rather than a branch, and a value baked into the assembly is one a crash report can be asked
for. See DesktopChannel, which is the only thing that reads it.
-->
<AssemblyMetadata Include="DodoChannel" Value="$(DodoChannel)" />
</ItemGroup>
<!--
Two native symbol files, and they are the reason a self-contained publish weighed 227 MB.
@@ -102,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,66 @@
using System.Reflection;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Which of the two desktop channels this build belongs to.
/// </summary>
/// <remarks>
/// <para>
/// The counterpart of the Android head's <c>DodoChannel</c> metadata, read the same way and for the same
/// reasons. Three things depend on the answer and they are listed here rather than discovered one at a
/// time: which Velopack channel the updater reads, whether that read considers prereleases, and which
/// profile directory this copy keeps its cache and device key in.
/// </para>
/// <para>
/// <b>Android gets this separation from the platform and this head has to build it.</b> Two Android
/// channels cannot replace one another because the installer refuses a package signed by a different key.
/// Nothing refuses anything on Windows: Velopack applies what its feed serves, without verifying a
/// signature. So the two channels are kept apart by construction here — a pack id each, so they install
/// in different directories; a Velopack channel each, so neither ever reads the other's release index; and
/// a profile directory each, so a nightly cannot migrate the schema of a cache the release build is using.
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c> rule 9.
/// </para>
/// <para>
/// Resolved once, at first use. The channel of a build does not change while it runs, and a value read
/// per call site is one that eventually gets read differently in two places.
/// </para>
/// </remarks>
internal static class DesktopChannel
{
/// <summary>The build this channel is not: the one a person cuts from a tag.</summary>
internal const string Release = "release";
/// <summary>The build CI publishes from main, which installs beside the release one.</summary>
internal const string Nightly = "nightly";
/// <summary>
/// What this build says it is, defaulting to the release channel.
/// </summary>
/// <remarks>
/// The csproj always writes the metadata, so the fallback covers a build that reached here some other
/// way — an assembly loaded by a test host, a designer, a trimmed-down copy. <see cref="Release"/> is
/// the safe answer for all of them: it is what the value was before there were two channels, and every
/// consequence of being wrong about it is inert. A build that is not installed has no updater at all
/// (see <c>UpdateChannels.ForThisMachine</c>), and the profile directory it names is the one every
/// existing install already uses.
/// </remarks>
internal static string Name { get; } = Read();
/// <summary>Whether this build belongs to the nightly channel.</summary>
internal static bool IsNightly => string.Equals(Name, Nightly, StringComparison.Ordinal);
private static string Read()
{
var declared = typeof(DesktopChannel).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(attribute => string.Equals(attribute.Key, "DodoChannel", StringComparison.Ordinal))
?.Value;
// Only the two the csproj declares are honoured. An unrecognised value is a build made by
// something nobody here wrote, and answering "release" to it is the same inert default as
// answering it to no value at all — rather than pointing an updater at a feed named by a string
// of unknown origin.
return string.Equals(declared, Nightly, StringComparison.Ordinal) ? Nightly : Release;
}
}
@@ -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,598 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using DodoSSH.Client.Session;
using static DodoSSH.Client.App.Platform.MacSecurity;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Keeps the device key encrypted to a Secure Enclave key whose use requires the user's presence.
/// </summary>
/// <remarks>
/// <para>
/// The macOS counterpart of <see cref="WindowsDeviceKeyStore"/>, and the same argument holds it up:
/// <b>the consent is enforced by the platform, not by this class</b>. The unwrapping key is generated
/// inside the Secure Enclave and never leaves it — there is no code path, privileged or otherwise, that
/// turns it into bytes — and it is created under an access control requiring
/// <see cref="AccessControlFlags.UserPresence"/>, so Touch ID or the login password is a condition of
/// <em>using</em> it. Malware running as the user can ask for a decryption; it cannot answer the prompt,
/// and the attempt is visible.
/// </para>
/// <para>
/// A store that showed its own prompt and then read a protected file would be trivially bypassed, which
/// is the mistake ADR 0007 originally described and the Windows store's comment corrects. The correction
/// applies here unchanged.
/// </para>
/// <para>
/// <b>P-256 and ECIES, where Windows uses RSA-OAEP, and the difference is not a preference.</b> The
/// Secure Enclave holds exactly one kind of key: a 256-bit key on the NIST P-256 curve. It will not hold
/// an RSA key at any size. So the wrap is <c>eciesEncryptionCofactorX963SHA256AESGCM</c> — an ephemeral
/// agreement against the enclave's public half, X9.63-KDF to an AES-GCM key, and the ephemeral public
/// key carried in the output. The framework does all of that; what matters here is that the input is 32
/// bytes and there is no size limit worth worrying about.
/// </para>
/// <para>
/// <b>Sealing is silent and unsealing prompts, which is better than the Windows shape rather than merely
/// different.</b> On Windows, <c>CngKey.Create</c> with <c>ProtectKey</c> raises a dialog at creation as
/// well, because the policy means "protect this key with a PIN" and Windows sets that up there and then.
/// Here <see cref="SecKeyCopyPublicKey"/> works on an enclave key without any prompt, so registering a
/// device shows nothing and only unlock asks. <see cref="SaveAsync"/> is therefore not user-facing on
/// this platform — but it is still called from where the Windows one has to be, and relying on that
/// difference would make the shared caller platform-specific for no gain.
/// </para>
/// <para>
/// <b>What this cannot be tested against, and what follows from that.</b> Every method except
/// <see cref="IsAvailableAsync"/> and the empty case of <see cref="TryLoadAsync"/> needs an interactive
/// login session and real enclave hardware, so none can be exercised by an automated test — the same
/// line the Windows store draws. It also means <see cref="IsSupported"/> must probe rather than infer:
/// see its remarks for the three ordinary machines that have no usable enclave and must degrade to the
/// passphrase rather than fail at unlock.
/// </para>
/// </remarks>
[SupportedOSPlatform("macos")]
public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
{
/// <summary>
/// The keychain tag this application's enclave key is filed under.
/// </summary>
/// <remarks>
/// Versioned for the reason the Windows key name is: a future change of curve or wrap algorithm can
/// create a new key beside the old one rather than failing to open blobs written by a previous
/// build. A device that cannot be opened falls back to the passphrase, which is survivable — but
/// silently, and a user would only notice their fingerprint had stopped working.
///
/// Prefixed with the bundle identifier because the keychain is shared across every application the
/// user runs, unlike a CNG key name, which is scoped to the user's key store already.
/// </remarks>
private const string KeyTag = "dev.dodotech.dodossh.devicekey.v1";
/// <summary>
/// Shown in the Touch ID prompt, so it has to read as a sentence to a person.
/// </summary>
/// <remarks>
/// macOS composes it into "DodoSSH is trying to ...", so this is a verb phrase and not a sentence of
/// its own. The same words the Windows consent dialog uses.
/// </remarks>
private const string ConsentPrompt = "unlock your DodoSSH vault";
private readonly ClientPaths paths;
/// <summary>Creates the store.</summary>
public MacDeviceKeyStore(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
this.paths = paths;
}
/// <summary>
/// Whether this Mac has a Secure Enclave that will hold a key for this build.
/// </summary>
/// <remarks>
/// <para>
/// Probed by creating a throwaway key and deleting it, rather than by asking whether the hardware
/// exists. Three ordinary situations answer "no" here and would otherwise only be discovered at the
/// moment somebody tried to unlock:
/// </para>
/// <para>
/// <b>An Intel Mac with no T2.</b> Apple Silicon and T2 machines have an enclave; earlier Intel
/// models do not, and there is no single attribute that says so.
/// </para>
/// <para>
/// <b>A build that is not code signed.</b> Enclave key creation requires a signing identity, so
/// every <c>dotnet run</c> and every build from an IDE fails here with a missing-entitlement error.
/// That is the correct answer rather than a nuisance: a development build should keep asking for the
/// passphrase, and this is what makes it do so without a platform check somewhere else.
/// </para>
/// <para>
/// <b>A machine with no login password set.</b> <see cref="AccessControlFlags.UserPresence"/> has
/// nothing to demand, and the framework refuses the access control object rather than silently
/// creating a key anybody could use.
/// </para>
/// <para>
/// The probe uses its own tag and no UI policy, so nothing prompts and nothing collides with the
/// real key. It is deleted immediately; a probe key left behind would accumulate one per launch.
/// </para>
/// </remarks>
internal static bool IsSupported()
{
try
{
var probe = $"{KeyTag}.probe.{Guid.CreateVersion7():N}";
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
if (!symbols.Complete)
{
return false;
}
var key = CreateEnclaveKey(scope, symbols, probe);
if (key == IntPtr.Zero)
{
return false;
}
// Discarded deliberately. The question this method answers is whether the enclave will make a
// key, and it demonstrably just did; a failure to clean the probe up afterwards leaves one
// stray keychain item and does not make the answer no.
_ = DeleteKey(symbols, probe);
return true;
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
// A macOS without these frameworks is not a thing that exists, so this is really the guard
// for the case that does: a future release renaming or removing one of them. The answer is
// the same as for hardware that is absent — no device key, ask for the passphrase.
return false;
}
}
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(IsSupported());
/// <inheritdoc />
public async ValueTask SaveAsync(
ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken)
{
var sealedKey = Seal(devicePrivateKey.Span)
?? throw new InvalidOperationException(
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
paths.EnsureCreated();
await File.WriteAllBytesAsync(paths.DeviceKeyFile, sealedKey, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(paths.DeviceKeyFile))
{
return null;
}
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false);
return Unseal(sealedKey);
}
/// <inheritdoc />
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
if (File.Exists(paths.DeviceKeyFile))
{
File.Delete(paths.DeviceKeyFile);
}
var symbols = MacSymbols.Resolve();
if (symbols.Complete)
{
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
// and the file deleted above is the half that decides whether unlock will try at all. A key
// left in the enclave with no ciphertext to open is inert.
_ = DeleteKey(symbols, KeyTag);
}
return ValueTask.CompletedTask;
}
/// <remarks>
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
/// them is the same — do not offer a device unlock.
/// </remarks>
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
{
try
{
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
if (!symbols.Complete)
{
return null;
}
// Created on first use rather than at registration, so that a device key re-registered after
// a ForgetAsync gets a key again without anything having to notice that it had gone.
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);
if (privateKey == IntPtr.Zero)
{
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
}
if (privateKey == IntPtr.Zero)
{
return null;
}
var publicKey = scope.Keep(SecKeyCopyPublicKey(privateKey));
if (publicKey == IntPtr.Zero)
{
return null;
}
var plaintext = Data(scope, devicePrivateKey);
if (plaintext == IntPtr.Zero)
{
return null;
}
var ciphertext = scope.Keep(
SecKeyCreateEncryptedData(publicKey, symbols.EciesAlgorithm, plaintext, out var error));
scope.Keep(error);
return ciphertext == IntPtr.Zero ? null : ToArray(ciphertext);
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
return null;
}
}
/// <remarks>
/// <para>
/// This is the call that prompts. Every failure becomes null, and the set is wider than it looks:
/// the key may be gone, the user may have cancelled or let the prompt time out, the enclave may have
/// invalidated it after the login password was reset, or the blob may predate a key that has since
/// been replaced. None of them are distinguishable to a user and all have the same remedy, so none
/// are worth telling apart here — see <c>UnlockStatus.DeviceKeyUnavailable</c>.
/// </para>
/// <para>
/// Blocking, and it blocks on a person. The prompt is modal to the application, so this must not run
/// on a thread that is also expected to draw the window behind it.
/// </para>
/// </remarks>
private static byte[]? Unseal(byte[] sealedKey)
{
try
{
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
if (!symbols.Complete)
{
return null;
}
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
if (privateKey == IntPtr.Zero)
{
return null;
}
var ciphertext = Data(scope, sealedKey);
if (ciphertext == IntPtr.Zero)
{
return null;
}
var plaintext = scope.Keep(
SecKeyCreateDecryptedData(privateKey, symbols.EciesAlgorithm, ciphertext, out var error));
scope.Keep(error);
return plaintext == IntPtr.Zero ? null : ToArray(plaintext);
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
return null;
}
}
/// <summary>
/// Generates a key inside the Secure Enclave, filed under <paramref name="tag"/>. Owned by the scope.
/// </summary>
/// <remarks>
/// <para>
/// The attribute dictionary is the whole security decision, so it is worth reading rather than
/// pattern-matching. <c>TokenID = SecureEnclave</c> is what puts the private half in hardware;
/// without it this silently generates an ordinary software key that behaves identically in every
/// visible way and protects nothing.
/// </para>
/// <para>
/// <c>AccessibleWhenUnlockedThisDeviceOnly</c> rather than any of the migratable classes, because a
/// device key that could be restored onto another machine from a backup would no longer mean "this
/// machine". The enclave already makes that impossible; saying it as well means the intent survives
/// a future change of storage.
/// </para>
/// <para>
/// <c>UseDataProtectionKeychain</c> is the macOS-specific one and the easiest to omit. Without it,
/// macOS routes this to the older file-based keychain, which does not understand access control
/// objects or the enclave, and the call fails with a parameter error that says nothing about the
/// missing key.
/// </para>
/// </remarks>
private static IntPtr CreateEnclaveKey(CoreFoundationScope scope, MacSymbols symbols, string tag)
{
var access = scope.Keep(SecAccessControlCreateWithFlags(
IntPtr.Zero,
symbols.AccessibleWhenUnlockedThisDeviceOnly,
AccessControlFlags.PrivateKeyUsage | AccessControlFlags.UserPresence,
out var accessError));
scope.Keep(accessError);
if (access == IntPtr.Zero)
{
return IntPtr.Zero;
}
var privateAttrs = Dictionary(
scope,
[symbols.AttrIsPermanent, symbols.AttrApplicationTag, symbols.AttrAccessControl],
[symbols.True, TagData(scope, tag), access]);
if (privateAttrs == IntPtr.Zero)
{
return IntPtr.Zero;
}
var keySize = Number(scope, 256);
var parameters = Dictionary(
scope,
[
symbols.AttrKeyType,
symbols.AttrKeySizeInBits,
symbols.AttrTokenId,
symbols.UseDataProtectionKeychain,
symbols.PrivateKeyAttrs,
],
[
symbols.KeyTypeEcSecPrimeRandom,
keySize,
symbols.TokenIdSecureEnclave,
symbols.True,
privateAttrs,
]);
if (parameters == IntPtr.Zero)
{
return IntPtr.Zero;
}
var key = scope.Keep(SecKeyCreateRandomKey(parameters, out var error));
scope.Keep(error);
return key;
}
/// <summary>
/// Looks the enclave key up by tag. Owned by the scope; zero when there is none.
/// </summary>
/// <remarks>
/// <paramref name="prompt"/> is attached here and consumed later: the lookup itself does not raise
/// anything, because a handle to an enclave key is not a use of it. The words reach the user at the
/// decrypt, which is the operation the access control actually guards.
///
/// <c>UseOperationPrompt</c> is deprecated in favour of an <c>LAContext</c>, and is used anyway. An
/// LAContext would mean binding LocalAuthentication as well for one string, and the deprecated key
/// still works; the day it stops, this call fails and the store degrades to the passphrase, which is
/// the failure this whole class is built to degrade into.
/// </remarks>
private static IntPtr FindKey(CoreFoundationScope scope, MacSymbols symbols, string tag, string? prompt)
{
List<IntPtr> keys =
[
symbols.Class,
symbols.AttrApplicationTag,
symbols.AttrKeyType,
symbols.UseDataProtectionKeychain,
symbols.ReturnRef,
];
List<IntPtr> values =
[
symbols.ClassKey,
TagData(scope, tag),
symbols.KeyTypeEcSecPrimeRandom,
symbols.True,
symbols.True,
];
if (prompt is not null)
{
keys.Add(symbols.UseOperationPrompt);
values.Add(scope.Keep(CFString(prompt)));
}
var query = Dictionary(scope, [.. keys], [.. values]);
if (query == IntPtr.Zero)
{
return IntPtr.Zero;
}
var status = SecItemCopyMatching(query, out var result);
// errSecItemNotFound is the ordinary answer on a machine that has never registered a device, and
// it is not distinguished from any other failure for the reason the class remarks give.
return status == Success ? scope.Keep(result) : IntPtr.Zero;
}
/// <summary>Removes the key with this tag from the keychain.</summary>
/// <returns>Whether the keychain now has no key under this tag.</returns>
/// <remarks>
/// <c>ItemNotFound</c> counts as success, and that is the common case rather than an edge: it is
/// what a machine that never registered a device answers, and what the second of two
/// <see cref="ForgetAsync"/> calls answers. Treating it as a failure would make forgetting a device
/// twice report a problem that does not exist.
/// </remarks>
private static bool DeleteKey(MacSymbols symbols, string tag)
{
using var scope = new CoreFoundationScope();
var query = Dictionary(
scope,
[symbols.Class, symbols.AttrApplicationTag, symbols.UseDataProtectionKeychain],
[symbols.ClassKey, TagData(scope, tag), symbols.True]);
if (query == IntPtr.Zero)
{
return false;
}
var status = SecItemDelete(query);
return status is Success or ItemNotFound;
}
// ---- Small CoreFoundation conveniences ---------------------------------------------------------
/// <remarks>
/// The arrays are pinned for the duration of the call and not beyond it, which is correct because
/// <c>CFDictionaryCreate</c> copies them: the dictionary retains each key and value, and never reads
/// the arrays again.
/// </remarks>
private static IntPtr Dictionary(CoreFoundationScope scope, IntPtr[] keys, IntPtr[] values)
{
// A zero anywhere means one of the constants did not resolve or an earlier allocation failed.
// Passing it on produces a dictionary with a null key, which CFDictionaryCreate does not reject
// — it crashes inside the callback table instead.
if (Array.IndexOf(keys, IntPtr.Zero) >= 0 || Array.IndexOf(values, IntPtr.Zero) >= 0)
{
return IntPtr.Zero;
}
var symbols = MacSymbols.Resolve();
unsafe
{
fixed (IntPtr* keyPtr = keys)
fixed (IntPtr* valuePtr = values)
{
return scope.Keep(CFDictionaryCreate(
IntPtr.Zero,
(IntPtr)keyPtr,
(IntPtr)valuePtr,
keys.Length,
symbols.TypeDictionaryKeyCallBacks,
symbols.TypeDictionaryValueCallBacks));
}
}
}
/// <summary>Copies bytes into a CFData. Owned by the scope.</summary>
/// <remarks>
/// The pin lasts only as long as the call, which is correct: <c>CFDataCreate</c> copies, so the
/// CFData does not reference this memory afterwards. <c>CFDataCreateWithBytesNoCopy</c> would not,
/// and is not used for exactly that reason — it would hand the framework a pointer into the managed
/// heap and rely on the object staying where the collector first put it.
/// </remarks>
private static IntPtr Data(CoreFoundationScope scope, ReadOnlySpan<byte> bytes)
{
unsafe
{
fixed (byte* pointer = bytes)
{
return scope.Keep(CFDataCreate(IntPtr.Zero, (IntPtr)pointer, bytes.Length));
}
}
}
/// <remarks>
/// UTF-8 rather than any other encoding, and it only has to be consistent with itself: the tag is an
/// opaque blob the keychain matches byte for byte, so what matters is that a lookup encodes it the
/// same way the creation did. It is written once, here, for exactly that reason.
/// </remarks>
private static IntPtr TagData(CoreFoundationScope scope, string tag) =>
Data(scope, Encoding.UTF8.GetBytes(tag));
private static IntPtr Number(CoreFoundationScope scope, int value)
{
unsafe
{
return scope.Keep(CFNumberCreate(IntPtr.Zero, (nint)CFNumberIntType, (IntPtr)(&value)));
}
}
/// <summary>Builds a CFString from a managed string. Owned, so the caller tracks it.</summary>
/// <remarks>
/// Built explicitly rather than left to the marshaller, because these calls take a
/// <c>CFStringRef</c> and not a C string — the runtime's default marshalling would hand over a
/// <c>char*</c>, which CoreFoundation reads as an object pointer and follows into nothing.
/// </remarks>
private static IntPtr CFString(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
unsafe
{
fixed (byte* pointer = bytes)
{
// kCFStringEncodingUTF8 is 0x08000100, spelled out rather than named because it is the
// only encoding constant this file uses.
return CFStringCreateWithBytes(IntPtr.Zero, (IntPtr)pointer, bytes.Length, 0x08000100, false);
}
}
}
[LibraryImport(CoreFoundation)]
private static partial IntPtr CFStringCreateWithBytes(
IntPtr allocator,
IntPtr bytes,
nint numBytes,
uint encoding,
[MarshalAs(UnmanagedType.U1)] bool isExternalRepresentation);
private static byte[] ToArray(IntPtr data)
{
var length = (int)CFDataGetLength(data);
var pointer = CFDataGetBytePtr(data);
if (length <= 0 || pointer == IntPtr.Zero)
{
return [];
}
var result = new byte[length];
Marshal.Copy(pointer, result, 0, length);
return result;
}
}
@@ -0,0 +1,254 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// The pieces of CoreFoundation and Security.framework <see cref="MacDeviceKeyStore"/> needs.
/// </summary>
/// <remarks>
/// <para>
/// Separated from the store itself because it is a different kind of code with a different kind of
/// review: nothing here makes a decision, and everything here is a translation of a C declaration that
/// is either right or wrong. Mixing the two would mean the security argument in
/// <see cref="MacDeviceKeyStore"/> had to be read past two hundred lines of marshalling to find.
/// </para>
/// <para>
/// <b>Every Create or Copy returns an object this process owns.</b> That is CoreFoundation's Create
/// Rule, and it is the thing here that goes wrong silently: the enclave key handle is small, so a leak
/// shows up as nothing at all until a long-running process has done a few thousand unlocks.
/// <see cref="CoreFoundationScope"/> exists so ownership is tracked by construction rather than by
/// remembering, and every function below that returns a handle says whether it is owned.
/// </para>
/// <para>
/// <b>The integer widths are the part worth checking against the headers rather than skimming.</b>
/// <c>CFIndex</c>, <c>CFOptionFlags</c> and <c>CFNumberType</c> are all pointer-width on a 64-bit Mac,
/// not 32-bit, and getting one wrong does not fail cleanly — it shifts every argument after it, so the
/// call receives plausible rubbish and returns a parameter error that names nothing.
/// </para>
/// </remarks>
[SupportedOSPlatform("macos")]
internal static partial class MacSecurity
{
internal const string SecurityFramework =
"/System/Library/Frameworks/Security.framework/Security";
internal const string CoreFoundation =
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
/// <summary>
/// The access control flags <c>SecAccessControlCreateWithFlags</c> takes.
/// </summary>
/// <remarks>
/// <c>ulong</c> because the parameter is a <c>CFOptionFlags</c>, which is an <c>unsigned long</c>.
/// Only the two flags that are used are listed; the full set is large, and copying it in would
/// invite somebody to reach for one without reading what it does to the prompt — <c>Biometry</c>
/// alone, for instance, leaves a Mac with no Touch ID unable to unlock at all rather than falling
/// back to the login password.
/// </remarks>
[Flags]
internal enum AccessControlFlags : ulong
{
/// <summary>
/// Touch ID if the machine has it, the login password if not.
/// </summary>
/// <remarks>
/// The forgiving one, deliberately. <c>BiometryCurrentSet</c> would additionally invalidate the
/// key whenever a fingerprint is added or removed, which sounds stricter and here buys nothing:
/// this key wraps a device key whose loss already means "ask for the passphrase", so the only
/// effect would be users being sent back to their passphrase by an unrelated Settings change
/// they would never connect to it.
/// </remarks>
UserPresence = 1ul << 0,
/// <summary>Required for any key that lives in the Secure Enclave.</summary>
PrivateKeyUsage = 1ul << 30,
}
/// <summary>The CFNumberType code for a 32-bit int, from CFNumber.h.</summary>
internal const long CFNumberIntType = 9;
/// <summary>errSecSuccess.</summary>
internal const int Success = 0;
/// <summary>errSecItemNotFound, which is an answer rather than a failure.</summary>
internal const int ItemNotFound = -25300;
// ---- CoreFoundation ------------------------------------------------------------------------------
/// <summary>Releases an owned handle.</summary>
[LibraryImport(CoreFoundation)]
internal static partial void CFRelease(IntPtr handle);
/// <summary>Copies bytes into a new CFData. Owned.</summary>
[LibraryImport(CoreFoundation)]
internal static partial IntPtr CFDataCreate(IntPtr allocator, IntPtr bytes, nint length);
[LibraryImport(CoreFoundation)]
internal static partial IntPtr CFDataGetBytePtr(IntPtr data);
[LibraryImport(CoreFoundation)]
internal static partial nint CFDataGetLength(IntPtr data);
/// <summary>Boxes a value as a CFNumber. Owned.</summary>
[LibraryImport(CoreFoundation)]
internal static partial IntPtr CFNumberCreate(IntPtr allocator, nint theType, IntPtr valuePtr);
/// <summary>Builds an immutable dictionary. Owned.</summary>
/// <remarks>
/// <para>
/// The key and value arrays are passed as raw pointers to memory the caller pins, rather than as
/// managed arrays. Source-generated interop wants an explicit element count for a marshalled array,
/// and supplying one here would mean stating the length twice — once for the marshaller and once as
/// <paramref name="numValues"/> — which is exactly the pair that drifts.
/// </para>
/// <para>
/// The two callback tables are what make the dictionary retain its keys and values, which is why
/// they are passed rather than left null: with null callbacks the dictionary stores raw pointers and
/// keeps nothing alive, and the resulting use-after-free is intermittent by nature.
/// </para>
/// </remarks>
[LibraryImport(CoreFoundation)]
internal static partial IntPtr CFDictionaryCreate(
IntPtr allocator,
IntPtr keys,
IntPtr values,
nint numValues,
IntPtr keyCallBacks,
IntPtr valueCallBacks);
// ---- Security.framework --------------------------------------------------------------------------
/// <summary>Builds the access policy a Secure Enclave key is created under. Owned.</summary>
[LibraryImport(SecurityFramework)]
internal static partial IntPtr SecAccessControlCreateWithFlags(
IntPtr allocator,
IntPtr protection,
AccessControlFlags flags,
out IntPtr error);
/// <summary>Creates a key pair from an attribute dictionary. Owned.</summary>
[LibraryImport(SecurityFramework)]
internal static partial IntPtr SecKeyCreateRandomKey(IntPtr parameters, out IntPtr error);
/// <summary>The public half of a key. Owned.</summary>
/// <remarks>
/// Available even for an enclave key, and that asymmetry is the whole reason this design works: the
/// public half is an ordinary key this process can hold and use, while the private half is a handle
/// to something inside the enclave that never becomes bytes. So sealing is silent and unsealing is
/// the thing the user is asked about.
/// </remarks>
[LibraryImport(SecurityFramework)]
internal static partial IntPtr SecKeyCopyPublicKey(IntPtr key);
/// <summary>Encrypts with a public key. Owned.</summary>
[LibraryImport(SecurityFramework)]
internal static partial IntPtr SecKeyCreateEncryptedData(
IntPtr key,
IntPtr algorithm,
IntPtr plaintext,
out IntPtr error);
/// <summary>Decrypts with a private key, prompting for whatever guards it. Owned.</summary>
[LibraryImport(SecurityFramework)]
internal static partial IntPtr SecKeyCreateDecryptedData(
IntPtr key,
IntPtr algorithm,
IntPtr ciphertext,
out IntPtr error);
/// <summary>Finds a keychain item. The out handle is owned when the result is <see cref="Success"/>.</summary>
[LibraryImport(SecurityFramework)]
internal static partial int SecItemCopyMatching(IntPtr query, out IntPtr result);
/// <summary>Deletes every keychain item matching the query.</summary>
[LibraryImport(SecurityFramework)]
internal static partial int SecItemDelete(IntPtr query);
// ---- The framework constants ---------------------------------------------------------------------
/// <summary>
/// Reads one of a framework's global CFString constants, or zero if it is not exported.
/// </summary>
/// <remarks>
/// <para>
/// The keys these dictionaries take are not strings this code may spell for itself. They are
/// pointer-comparable constants exported by the framework, and a CFString built here with the same
/// characters is a different object — the lookups would miss and the call would fail with a
/// parameter error naming nothing.
/// </para>
/// <para>
/// <b>Dereferenced once, because the exported symbol is the variable rather than its value.</b>
/// <c>TryGetExport</c> answers the address of the global; the CFStringRef is what that address
/// holds. Missing the indirection produces a pointer that is stable, plausible and wrong, which is
/// the worst of the three available outcomes.
/// </para>
/// <para>
/// Zero on a missing symbol rather than an exception, because the caller's answer to every failure
/// is the same one — report the store unavailable and let unlock ask for the passphrase — and a
/// constant that has been renamed by a future macOS should reach that answer rather than a crash.
/// </para>
/// </remarks>
internal static IntPtr Constant(IntPtr library, string symbol) =>
NativeLibrary.TryGetExport(library, symbol, out var address)
? Marshal.ReadIntPtr(address)
: IntPtr.Zero;
}
/// <summary>
/// Releases every CoreFoundation handle put into it, in reverse order, exactly once.
/// </summary>
/// <remarks>
/// <para>
/// The alternative is a try/finally per handle, and the operations here need six or seven at a time — a
/// dictionary holding a nested dictionary holding an access control object holding a CFData tag. Finallys
/// nested that deep stop being read, and a handle released twice is a crash rather than a leak.
/// </para>
/// <para>
/// <see cref="Keep"/> returns what it was given, so a handle can be tracked in the same expression that
/// produces it and the call sites read as ordinary code.
/// </para>
/// </remarks>
[SupportedOSPlatform("macos")]
internal sealed class CoreFoundationScope : IDisposable
{
private readonly List<IntPtr> owned = [];
private bool disposed;
/// <summary>Takes ownership of a handle and hands it straight back.</summary>
/// <remarks>
/// Zero is ignored rather than rejected. Every CoreFoundation call here answers zero on failure, so
/// accepting it lets a caller track the result in the expression that produces it and check it on
/// the next line, instead of writing the check twice.
/// </remarks>
internal IntPtr Keep(IntPtr handle)
{
if (handle != IntPtr.Zero)
{
owned.Add(handle);
}
return handle;
}
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
// Reverse order, so a container is released before the things it retains. CoreFoundation does not
// require it — retain counts make the order irrelevant — but it keeps the lifetimes readable in a
// debugger, where a released container that still lists its contents is a confusing thing to meet.
for (var i = owned.Count - 1; i >= 0; i--)
{
MacSecurity.CFRelease(owned[i]);
}
owned.Clear();
}
}
@@ -0,0 +1,185 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// The framework constants <see cref="MacDeviceKeyStore"/> passes to CoreFoundation and Security.
/// </summary>
/// <remarks>
/// <para>
/// Every field here is a pointer read out of a loaded framework rather than a value this code could
/// write down. The dictionaries these go into are matched by pointer identity, so a CFString built with
/// the same characters is a different key and the lookup misses — see <see cref="MacSecurity.Constant"/>
/// for the indirection that trips people.
/// </para>
/// <para>
/// <b>Resolved once and cached, and the caching is what makes the failure survivable.</b> Two frameworks
/// and nineteen symbols is a lot of things to be wrong about, and the useful property is that being
/// wrong about any one of them shows up here — as <see cref="Complete"/> being false — rather than
/// three calls later as a parameter error. A store that reports itself unavailable sends the user back
/// to their passphrase; a store that half works corrupts the moment somebody registers a device.
/// </para>
/// <para>
/// <see cref="Lazy{T}"/> rather than a static constructor, because a type initialiser that throws
/// poisons the type for the life of the process and turns a missing symbol into a
/// <c>TypeInitializationException</c> at every later call site. The load is done inside a try instead,
/// and its failure is a value.
/// </para>
/// </remarks>
[SupportedOSPlatform("macos")]
internal sealed class MacSymbols
{
private static readonly Lazy<MacSymbols> Cached = new(Load, LazyThreadSafetyMode.ExecutionAndPublication);
private MacSymbols()
{
}
/// <summary>Whether every symbol resolved.</summary>
/// <remarks>
/// Checked by every caller before any of the pointers are used. It is one check rather than
/// nineteen, which is the only reason the call sites in <see cref="MacDeviceKeyStore"/> are
/// readable.
///
/// Computed rather than stored, so that the instance returned when a framework will not load at all
/// — every field left at zero — answers false without that having to be set anywhere. One rule,
/// applied to the only state there is.
/// </remarks>
internal bool Complete => AllResolved();
// CoreFoundation.
internal IntPtr True { get; private init; }
internal IntPtr TypeDictionaryKeyCallBacks { get; private init; }
internal IntPtr TypeDictionaryValueCallBacks { get; private init; }
// Security: item classes and query keys.
internal IntPtr Class { get; private init; }
internal IntPtr ClassKey { get; private init; }
internal IntPtr ReturnRef { get; private init; }
internal IntPtr UseDataProtectionKeychain { get; private init; }
internal IntPtr UseOperationPrompt { get; private init; }
// Security: key attributes.
internal IntPtr AttrKeyType { get; private init; }
internal IntPtr AttrKeySizeInBits { get; private init; }
internal IntPtr AttrTokenId { get; private init; }
internal IntPtr AttrIsPermanent { get; private init; }
internal IntPtr AttrApplicationTag { get; private init; }
internal IntPtr AttrAccessControl { get; private init; }
internal IntPtr PrivateKeyAttrs { get; private init; }
// Security: attribute values.
internal IntPtr KeyTypeEcSecPrimeRandom { get; private init; }
internal IntPtr TokenIdSecureEnclave { get; private init; }
internal IntPtr AccessibleWhenUnlockedThisDeviceOnly { get; private init; }
/// <summary>
/// <c>kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM</c>.
/// </summary>
/// <remarks>
/// The one algorithm the Secure Enclave's P-256 keys support for encryption, and the reason this
/// store wraps rather than signs. The long name spells out the whole construction: an ephemeral
/// key agreed against the enclave's public half with cofactor ECDH, run through the X9.63 KDF with
/// SHA-256, used as an AES-GCM key. The ephemeral public key travels in the output, which is why the
/// ciphertext is larger than the 32 bytes going in and why nothing else has to be stored beside it.
/// </remarks>
internal IntPtr EciesAlgorithm { get; private init; }
/// <summary>The resolved symbols, loaded once.</summary>
internal static MacSymbols Resolve() => Cached.Value;
private static MacSymbols Load()
{
try
{
if (!NativeLibrary.TryLoad(MacSecurity.CoreFoundation, out var cf)
|| !NativeLibrary.TryLoad(MacSecurity.SecurityFramework, out var sec))
{
// Every pointer left at zero, which AllResolved reads as incomplete.
return new MacSymbols();
}
// The two callback tables are structs rather than object pointers, so what is wanted is the
// address of the export itself and not what it holds. Every other symbol here is a CFTypeRef
// global and needs the dereference; these two do not, and mixing them up produces a
// dictionary that does not retain its contents.
var keyCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryKeyCallBacks", out var k)
? k
: IntPtr.Zero;
var valueCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryValueCallBacks", out var v)
? v
: IntPtr.Zero;
return new MacSymbols
{
True = MacSecurity.Constant(cf, "kCFBooleanTrue"),
TypeDictionaryKeyCallBacks = keyCallBacks,
TypeDictionaryValueCallBacks = valueCallBacks,
Class = MacSecurity.Constant(sec, "kSecClass"),
ClassKey = MacSecurity.Constant(sec, "kSecClassKey"),
ReturnRef = MacSecurity.Constant(sec, "kSecReturnRef"),
UseDataProtectionKeychain = MacSecurity.Constant(sec, "kSecUseDataProtectionKeychain"),
UseOperationPrompt = MacSecurity.Constant(sec, "kSecUseOperationPrompt"),
AttrKeyType = MacSecurity.Constant(sec, "kSecAttrKeyType"),
AttrKeySizeInBits = MacSecurity.Constant(sec, "kSecAttrKeySizeInBits"),
AttrTokenId = MacSecurity.Constant(sec, "kSecAttrTokenID"),
AttrIsPermanent = MacSecurity.Constant(sec, "kSecAttrIsPermanent"),
AttrApplicationTag = MacSecurity.Constant(sec, "kSecAttrApplicationTag"),
AttrAccessControl = MacSecurity.Constant(sec, "kSecAttrAccessControl"),
PrivateKeyAttrs = MacSecurity.Constant(sec, "kSecPrivateKeyAttrs"),
KeyTypeEcSecPrimeRandom = MacSecurity.Constant(sec, "kSecAttrKeyTypeECSECPrimeRandom"),
TokenIdSecureEnclave = MacSecurity.Constant(sec, "kSecAttrTokenIDSecureEnclave"),
AccessibleWhenUnlockedThisDeviceOnly =
MacSecurity.Constant(sec, "kSecAttrAccessibleWhenUnlockedThisDeviceOnly"),
EciesAlgorithm = MacSecurity.Constant(
sec,
"kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM"),
};
}
catch (Exception exception) when (exception is DllNotFoundException or BadImageFormatException)
{
return new MacSymbols();
}
}
private bool AllResolved() =>
True != IntPtr.Zero
&& TypeDictionaryKeyCallBacks != IntPtr.Zero
&& TypeDictionaryValueCallBacks != IntPtr.Zero
&& Class != IntPtr.Zero
&& ClassKey != IntPtr.Zero
&& ReturnRef != IntPtr.Zero
&& UseDataProtectionKeychain != IntPtr.Zero
&& UseOperationPrompt != IntPtr.Zero
&& AttrKeyType != IntPtr.Zero
&& AttrKeySizeInBits != IntPtr.Zero
&& AttrTokenId != IntPtr.Zero
&& AttrIsPermanent != IntPtr.Zero
&& AttrApplicationTag != IntPtr.Zero
&& AttrAccessControl != IntPtr.Zero
&& PrivateKeyAttrs != IntPtr.Zero
&& KeyTypeEcSecPrimeRandom != IntPtr.Zero
&& TokenIdSecureEnclave != IntPtr.Zero
&& AccessibleWhenUnlockedThisDeviceOnly != IntPtr.Zero
&& EciesAlgorithm != IntPtr.Zero;
}
@@ -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 == '^';
}
Loaded 100 of 367 files, more files were not shown because too many files have changed in this diff. Show more