Public Access
Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41f368ae75 | ||
|
|
24c18205c3 | ||
|
|
8759b12d47 | ||
|
|
7b072bcded | ||
|
|
b1d4a270d4 | ||
|
|
a2362d1e3e | ||
|
|
079a273d46 | ||
|
|
37e9d74938 | ||
|
|
170caa1391 | ||
|
|
965c9a7043 | ||
|
|
ff8a790f6c | ||
|
|
b2516ca7fb | ||
|
|
36fb0817a4 | ||
|
|
cc3d46f78c | ||
|
|
8bee476039 | ||
|
|
441709f1e9 | ||
|
|
873405e591 | ||
|
|
f997e87c5f | ||
|
|
365954c541 | ||
|
|
3ccafdd53b | ||
|
|
22b4d82ba8 | ||
|
|
619f49a35a | ||
|
|
a6d3924e0d | ||
|
|
079914bfb4 | ||
|
|
6c09bdecb6 | ||
|
|
372a7cbdc7 | ||
|
|
a4b81da597 | ||
|
|
0e8fe4fc80 | ||
|
|
2ff9dff560 | ||
|
|
6846f67c20 | ||
|
|
f332d9a56f | ||
|
|
03c8e8589a | ||
|
|
4c7f47c026 | ||
|
|
412c314aec | ||
|
|
b08f2c58e3 | ||
|
|
ee423b25ce | ||
|
|
38ab9b431b | ||
|
|
07bd5c9886 | ||
|
|
31aca26e89 | ||
|
|
843713b118 | ||
|
|
9c7be8ae9b | ||
|
|
3d0a66f997 | ||
|
|
689ba6c1bf | ||
|
|
6e39807cc0 | ||
|
|
d7d002c972 | ||
|
|
c3dfa42108 | ||
|
|
a6a21decbd | ||
|
|
f05deb4fbc | ||
|
|
c8848ec1e3 | ||
|
|
ab432e475e | ||
|
|
b20f7cae04 | ||
|
|
54bc142dce | ||
|
|
8e61fc72b4 | ||
|
|
8a4058ce3a | ||
|
|
2d95366552 | ||
|
|
9ead42171c | ||
|
|
597e5ae016 | ||
|
|
483ae1fed9 | ||
|
|
7ee2aacdc9 | ||
|
|
dec2cf164a | ||
|
|
7d1f0a4b53 | ||
|
|
fd92b80d4f | ||
|
|
dabc691c35 | ||
|
|
31c836f6ca | ||
|
|
b8c94ecb73 | ||
|
|
aff776cb42 | ||
|
|
91f7586b22 | ||
|
|
c859705b83 | ||
|
|
ed936380b3 | ||
|
|
c270f3fe39 | ||
|
|
56c19b006d | ||
|
|
1908189100 | ||
|
|
2810f7c12b | ||
|
|
c0dd5a147e | ||
|
|
c0396b4f4d |
No files matched your search
@@ -29,6 +29,18 @@ jobs:
|
||||
build:
|
||||
name: build and test
|
||||
runs-on: [linux]
|
||||
# ◆ A CEILING, BECAUSE THIS FILE HAD NONE AND A HUNG TEST RAN UNTIL SOMEBODY NOTICED.
|
||||
#
|
||||
# One test in DodoSSH.Client.App.Tests waited on a named pipe that, on Linux, nothing was ever going
|
||||
# to answer — and it waited on a token with no timeout, so the job did not fail, it sat there. It was
|
||||
# killed by hand after twenty minutes, having reported nothing but the eighteen suites that had
|
||||
# already passed.
|
||||
#
|
||||
# Twenty-five minutes against a job that ordinarily takes three. It is not a performance budget and
|
||||
# must not be read as one: a build that legitimately grows past this should have the number raised.
|
||||
# What it buys is that the failure mode is a red job with a named step rather than a runner somebody
|
||||
# has to go and stop.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
# act_runner runs every `uses:` action with node inside the job container, and this
|
||||
# runner's image has none — the run died on the first line of actions/checkout with
|
||||
@@ -220,7 +232,12 @@ jobs:
|
||||
- name: build
|
||||
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
|
||||
|
||||
# Tighter than the job's own ceiling, and deliberately so: a hang here is the likeliest of the
|
||||
# lot — it is the only step running code written for this repository rather than a tool — and a
|
||||
# step-level timeout names the step in the failure instead of leaving a reader to work out which
|
||||
# of fifteen was still running. Ten minutes against a suite that takes two.
|
||||
- name: test
|
||||
timeout-minutes: 10
|
||||
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
||||
|
||||
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
||||
@@ -556,6 +573,8 @@ jobs:
|
||||
exit 0
|
||||
android:
|
||||
name: android head
|
||||
# See the build job. The docker pull and the toolchain image make this one legitimately slower.
|
||||
timeout-minutes: 40
|
||||
runs-on: [linux]
|
||||
# Writes the nightly release at the end of the job; see that step for what the capability is and why
|
||||
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
|
||||
@@ -915,7 +934,10 @@ jobs:
|
||||
# never made.
|
||||
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||
for name in $names; do
|
||||
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
||||
# grep without -q, which has the same exit status and reads to the end of its input. A
|
||||
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
|
||||
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
|
||||
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||
echo "The release was created but $name is not on it:" >&2
|
||||
printf '%s\n' "$published" >&2
|
||||
exit 1
|
||||
@@ -926,6 +948,8 @@ jobs:
|
||||
|
||||
desktop:
|
||||
name: desktop nightly
|
||||
# See the build job. Two publishes, a pack and an upload of about 120 MB.
|
||||
timeout-minutes: 40
|
||||
# Gated on the tests, like the image job and for a stronger version of its reason: this one is
|
||||
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
|
||||
# commit whose suite was red.
|
||||
@@ -1226,7 +1250,10 @@ jobs:
|
||||
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
|
||||
|
||||
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
|
||||
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
||||
# grep without -q, which has the same exit status and reads to the end of its input. A
|
||||
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
|
||||
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
|
||||
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||
echo "The release was created but $name is not on it:" >&2
|
||||
printf '%s\n' "$published" >&2
|
||||
exit 1
|
||||
@@ -1279,7 +1306,8 @@ jobs:
|
||||
# upload it never made.
|
||||
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||
for name in $linuxNames; do
|
||||
if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$name\""; then
|
||||
# grep without -q; see the sibling loops above and scripts/ci-linux-rpm.sh.
|
||||
if ! printf '%s' "$withRpm" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||
echo "The release was created but $name is not on it:" >&2
|
||||
printf '%s\n' "$withRpm" >&2
|
||||
exit 1
|
||||
@@ -1290,12 +1318,27 @@ jobs:
|
||||
|
||||
image:
|
||||
name: api image
|
||||
# See the build job. A cold layer cache is the slow case here.
|
||||
timeout-minutes: 40
|
||||
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
||||
# clock on every main commit and buys the thing worth having: no image reaches the
|
||||
# registry from a commit whose tests were red. An image is not a build artefact anyone
|
||||
# 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.
|
||||
@@ -1421,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"
|
||||
@@ -1548,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
|
||||
@@ -1566,7 +1606,7 @@ 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"
|
||||
|
||||
# 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
|
||||
|
||||
+8
-1
@@ -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/
|
||||
@@ -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,7 +69,8 @@ src/
|
||||
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
||||
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
||||
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
|
||||
DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked
|
||||
DodoSSH.Client.Import reading ~/.ssh/config and the keys it names when asked; reading a Hetzner
|
||||
Cloud project over its API
|
||||
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
|
||||
DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
|
||||
DodoSSH.Client.Android the phone head: its views, and its Android integration
|
||||
@@ -425,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,
|
||||
@@ -640,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.
|
||||
@@ -709,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
|
||||
@@ -872,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.
|
||||
|
||||
@@ -247,6 +247,13 @@ ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
|
||||
# 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]
|
||||
@@ -255,13 +262,14 @@ Version=1.0
|
||||
Name=%{dodo_title}
|
||||
GenericName=SSH Client
|
||||
Comment=SSH and SFTP with an end-to-end encrypted credential vault
|
||||
Exec=%{dodo_bin}
|
||||
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
|
||||
|
||||
@@ -119,6 +119,25 @@
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+14
-1
@@ -269,6 +269,12 @@ accessory key row for this. That is UI work, not porting.
|
||||
> reconnected; earlier output stayed on the host"* — is that honesty put where the person looking at the
|
||||
> terminal will actually read it, not buried in a log.
|
||||
|
||||
> **⚠️ Under review, on the input half only.** The renderer's story above stands; what has not held up in
|
||||
> use is *typing*. A software keyboard over a WebView cannot be told to stop autocorrecting — the DOM
|
||||
> attributes xterm.js already sets are advisory and Android ignores them — and the accessory row pays a
|
||||
> focus round-trip per tap. [ADR 0015](adr/0015-android-terminal-input.md) proposes moving the input path to
|
||||
> Avalonia and leaving the renderer exactly as this section describes it.
|
||||
|
||||
---
|
||||
|
||||
## Decisions taken
|
||||
@@ -625,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
|
||||
@@ -749,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
@@ -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
|
||||
|
||||
+40
-13
@@ -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. |
|
||||
@@ -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. |
|
||||
@@ -171,7 +171,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | 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
|
||||
@@ -254,7 +254,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | v5 element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The status dot's third, amber state | ◆ **Still two states, on the same reasoning v3 and v4 both gave and v5 was asked to reconsider.** Green means a terminal is open on that host, grey means one is not; nothing here pings a machine, so the amber "reachable but not connected" state has no fact behind it to draw. |
|
||||
> | The relay checkbox's mock copy | The pre-existing sentence, unchanged, "(not wired up yet)" included — restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it) but not reworded. The mock's own copy implies a relay this client can dial; it cannot, for the reason `HostDrawer.axaml`'s own remarks give. |
|
||||
> | The relay checkbox's mock copy | Restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it). The "(not wired up yet)" it carried while this client could not dial the relay came out when it could; the sentence under it now says what ticking it does — the server dials the host, so the server has to be able to reach it, and the address is stored in plain text. |
|
||||
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
|
||||
> | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. |
|
||||
> | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. |
|
||||
@@ -295,7 +295,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | 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 | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
|
||||
> | 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. |
|
||||
@@ -392,9 +392,10 @@ sharing that would rest on them — or a decision recorded here not to build the
|
||||
|
||||
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
|
||||
@@ -461,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.
|
||||
@@ -509,7 +512,7 @@ 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. | **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. |
|
||||
@@ -533,7 +536,7 @@ 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. |
|
||||
@@ -741,3 +744,27 @@ to a 34-pixel rail carrying the chevron that brings it back — a rail rather th
|
||||
that vanishes without trace is one people report as lost. The choice is written through to
|
||||
`ClientSettings.SessionSidebarOpen` rather than held for the session: it is a decision about how much of the
|
||||
window a terminal gets, and one that had to be made again on every launch would not really be on offer.
|
||||
|
||||
## v5c-5 — one door to settings, not three
|
||||
|
||||
**The user-chip popover keeps its Settings row and has lost Vaults and Preferences.** v5c gave it three, and
|
||||
the table above records why: until that pass "Settings" and "Preferences" had been two doors onto the same
|
||||
bare screen, so splitting them was a real fix, and Vaults arrived beside them when `VaultsScreen` became a
|
||||
settings page too.
|
||||
|
||||
Living with it made the shape of the mistake clear. All three opened the same full-window mode and differed
|
||||
only in which of its seven pages they landed on — and `SettingsNav`, which lists every one of those pages
|
||||
including Vaults and Preferences, is on screen the entire time you are in there. So two of the three rows
|
||||
were deep links into a place the third one opens, saving one click and costing a menu that read as though it
|
||||
offered three destinations. A menu at the foot of the rail is for getting into settings at all; which page
|
||||
you want is a question that has a rail of its own once you are there.
|
||||
|
||||
Nothing became unreachable: both pages are one click deeper, from the rail that was always going to be the
|
||||
way to them. `NavRailTests` asserts the rows are gone from the markup rather than merely unwired, because a
|
||||
row left behind with no handler is a menu entry that does nothing when pressed — which reads as a broken
|
||||
application rather than a tidied menu.
|
||||
|
||||
Logout stays, and still lands on a page rather than on General: its confirmation card is drawn inline on the
|
||||
Account page and nowhere else, so arming it from anywhere else would be a card raised where nobody could see
|
||||
it. The vault switches and **New vault** above the divider stay too — they are what the popover is for
|
||||
besides settings, and neither is a page of the settings mode.
|
||||
+413
-26
@@ -118,15 +118,17 @@ view models — this check is the version with a real connection on the end of i
|
||||
|
||||
### 1.8 Entering and leaving settings mode
|
||||
|
||||
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings**. Then repeat from a
|
||||
fresh open, choosing **Vaults**, and again choosing **Preferences**.
|
||||
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings** — which is the menu's
|
||||
only way in now; the Vaults and Preferences rows beside it were folded back into it, because all three
|
||||
opened this same mode and differed only in which page they landed on.
|
||||
|
||||
**Pass:** each of the three swaps the whole window's chrome — the titlebar becomes the 53px "Back to
|
||||
application" bar, the ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the
|
||||
update banner (if either was showing) are both gone. **Settings** lands on General; **Vaults** and
|
||||
**Preferences** land on their own pages, each lit in SettingsNav's own list — General/Vaults/Account under
|
||||
SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below both. Click every one of the
|
||||
seven rows in turn: each swaps the content column without leaving settings mode or touching the titlebar.
|
||||
**Pass:** it swaps the whole window's chrome — the titlebar becomes the 53px "Back to application" bar, the
|
||||
ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the update banner (if either
|
||||
was showing) are both gone. It lands on **General**, with SettingsNav's own list beside it —
|
||||
General/Vaults/Account under SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below
|
||||
both. Click every one of the seven rows in turn: each swaps the content column without leaving settings mode
|
||||
or touching the titlebar, and Vaults and Preferences are among them — which is the whole reason the popover
|
||||
no longer carries its own rows for those two.
|
||||
|
||||
Now open a terminal, leave it showing, and open Settings from the user chip again. Press **Back to
|
||||
application** — or, without touching anything else, press Esc.
|
||||
@@ -134,7 +136,8 @@ application** — or, without touching anything else, press Esc.
|
||||
**Pass:** both return to exactly the screen that was showing before Settings was opened — the terminal,
|
||||
still running, rather than HOSTS or anywhere else. Switching between settings pages first (General → Vaults
|
||||
→ Preferences) does not change what "back" goes back to; only the screen open the moment Settings was
|
||||
**first** entered does.
|
||||
**first** entered does. The other doors into this mode — the palette, and the Preferences page's own two
|
||||
importer rows — behave the same way, so the popover having one row rather than three changes nothing here.
|
||||
|
||||
**Failure means:** a return to HOSTS regardless of where Settings was opened from is
|
||||
`MainWindowViewModel.settingsReturnScreen` being recaptured on every `EnterSettings` call rather than only on
|
||||
@@ -165,7 +168,9 @@ ordinary nav rail destination does through `MainWindowViewModel.ShowScreen`. An
|
||||
comes up over settings mode the same way it would over any other screen.
|
||||
|
||||
**Failure means:** a back button that never changes label is `SettingsTitleBar.axaml`'s two buttons both
|
||||
bound to the same side of `IsImportOpen`. SettingsNav lighting a different row while the importer is up is
|
||||
bound to the same side of `IsImporterOpen` — which is the flag now, rather than `IsImportOpen`, because
|
||||
there are two importers over that page and the bar behaves identically for both. SettingsNav lighting a
|
||||
different row while an importer is up is
|
||||
`MainWindowViewModel.IsSettingsPreferencesPage` reading `IsImportOpen` when it must not —
|
||||
`Import.dc.html` draws the rail unmoved on purpose; see the remark on `IsImportOpen`. Ctrl+K doing nothing
|
||||
over the importer would be a guard added to `OnKeyDown` for `IsSettingsMode` that the design never asked for
|
||||
@@ -197,6 +202,126 @@ That is what the "is this still the page's socket" guard in `connect()` exists t
|
||||
input while the banner is *clear* is neither: the socket is open and dead, which nothing on this page can
|
||||
currently see — see the note at the end of 11.12a.
|
||||
|
||||
### 1.10a A remote that floods the terminal does not grow the process · **watch Task Manager**
|
||||
|
||||
Open a shell and run `yes dodossh`. Leave it running for a full minute with the tab in front of you, then
|
||||
switch to another screen and leave it another minute, then come back. Watch the application's memory in
|
||||
Task Manager (Details tab, *Memory (active private working set)*) the whole time.
|
||||
|
||||
**Pass:** memory settles at a plateau and stays there — the number moves as the GC breathes, but there is no
|
||||
staircase. Ctrl+C, and the prompt comes back straight away. Somewhere in the scrollback there may be a dim
|
||||
grey line reading `── N MB of output was dropped; the terminal could not keep up ──`; that line is a pass,
|
||||
not a failure. It is this client saying it discarded output it could not draw, which is what keeps the
|
||||
plateau flat, and the number after it tells you how far behind the display got.
|
||||
|
||||
**Failure means:** a working set that climbs steadily for the whole minute and does not come back down after
|
||||
Ctrl+C is the defect this check exists for, and it will not be in this repository's own buffers — it is
|
||||
SSH.NET's. `ShellStream` copies everything the channel delivers into a buffer that doubles and is never
|
||||
given back, and the channel keeps delivering because `Channel.OnData` re-opens the SSH receive window as
|
||||
bytes *arrive* rather than as they are consumed. Measured against the container in
|
||||
`DodoSSH.Client.Ssh.Tests`, a reader that stops entirely leaves that buffer holding 44 MB after five
|
||||
seconds. The only thing that keeps it empty is `TerminalSessionPump` reading continuously and bounding what
|
||||
it holds in `OutputBacklog` instead — so a staircase here means something has put a wait back into that read
|
||||
loop. `ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog` is the test that fails first.
|
||||
|
||||
### 1.10b A tab whose shell exited stops costing anything · **needs a debugger or `netstat`**
|
||||
|
||||
Open a shell, type `exit`, and **leave the tab open**. Note the host and port. Then, from a terminal outside
|
||||
the application, run `netstat -ano | findstr <port>` and look for the connection to that host.
|
||||
|
||||
**Pass:** the connection is gone within a second or two of the shell exiting, even though the tab is still on
|
||||
screen showing the session ended and still offering RECONNECT. Clicking that reconnect still works, and
|
||||
closing the tab afterwards still works.
|
||||
|
||||
**Failure means:** a connection that is still ESTABLISHED for as long as the tab is open is the session
|
||||
holding an authenticated socket it has no further use for — with a keep-alive putting a packet on the wire
|
||||
every thirty seconds and SSH.NET's read buffer still at whatever size the session's busiest moment grew it
|
||||
to. It used to do exactly that, until whichever tab it was got closed. `TerminalWorkspace.ReleaseAsync` is
|
||||
what gives it back now, and `ASessionWhoseRemoteHasExited_GivesBackItsConnectionWithoutWaitingForTheTab` is
|
||||
the test that says so.
|
||||
|
||||
### 1.11 The right button copies and pastes, and never opens a menu
|
||||
|
||||
Open a shell and run something that prints a few lines — `ls -la /etc`. Drag a selection across part of the
|
||||
output with the left button, then **right-click inside the selection**.
|
||||
|
||||
**Pass:** the highlight disappears at once, **no menu appears anywhere**, and the text is on the clipboard —
|
||||
paste it into Notepad, or into the connect box, to be sure. The pane still takes typing straight afterwards.
|
||||
|
||||
Now **right-click again with nothing selected.**
|
||||
|
||||
**Pass:** the clipboard's text appears at the prompt as one insertion and **Enter is not pressed** — it sits
|
||||
there waiting. On bash it will be highlighted in reverse video until the next keystroke, which is right: that
|
||||
is bash marking a bracketed paste, and it is how you can see the markers arrived.
|
||||
|
||||
**And the multi-line case, which is the one worth the most care.** Copy three separate lines out of a text
|
||||
editor — `cd /tmp`, `pwd`, `ls` — and right-click in the terminal.
|
||||
|
||||
**Pass:** all three lines land at the prompt as one thing being edited and **nothing runs** until Enter is
|
||||
pressed. Three commands executing the moment you right-click is the failure this is looking for.
|
||||
|
||||
**On a Mac, one more:** right-click over a word with nothing selected and confirm it pastes rather than
|
||||
selecting the word under the pointer. xterm.js defaults `rightClickSelectsWord` to true on macOS alone;
|
||||
`terminal.js` turns it off, and this is the one platform where forgetting to would show.
|
||||
|
||||
**What the tests already own.** `RendererClipboardTests` runs `terminal.js` itself and proves which frame a
|
||||
right-click sends, which session it is addressed to, that the selection is dropped after a copy and that the
|
||||
answer comes back through `paste()` rather than `input()`. `ShellFlowTests` proves the round trip through the
|
||||
host's own clipboard. What is left here is the WebView: that a real `contextmenu` event reaches a listener
|
||||
registered in the capture phase, that no native menu is drawn once it is prevented, and that xterm's real
|
||||
`paste()` brackets the text — none of which a Jint engine can answer.
|
||||
|
||||
**Failure means:**
|
||||
|
||||
- **A context menu appears.** The event is not being prevented, or not being seen. `handleRightClick` takes
|
||||
it in the capture phase on the pane; a listener registered without the third argument to
|
||||
`addEventListener` would still prevent the menu, so a menu means the listener is not attached at all.
|
||||
- **A permission prompt appears** — *"…wants to see text and images copied to the clipboard"*. Somebody has
|
||||
routed the read through `navigator.clipboard.readText()`, which is exactly what the `ClipboardPaste`
|
||||
opcode exists to avoid. The clipboard is the host's.
|
||||
- **A drag started exactly where you last right-clicked selects nothing.** xterm's own `contextmenu`
|
||||
handler ran: it parks its invisible helper textarea under the pointer, 20 pixels square at z-index 1000,
|
||||
to serve the native menu. The page stops the event as well as preventing it, and `stopPropagation` is the
|
||||
half that was left out. Subtle enough to be worth looking for on purpose rather than waiting to notice.
|
||||
- **Three commands run on a multi-line paste.** The text went through `input()` rather than `paste()`, so
|
||||
bracketed paste never happened and the shell read each newline as "run this". That is the whole reason the
|
||||
`Clipboard` opcode is separate from `Paste`; see `TerminalFrame.cs`.
|
||||
- **Nothing happens at all, in either direction.** The head is not passing a clipboard. Both pass one — see
|
||||
`ClipboardWriter` and `ClipboardReader` in each `App.axaml.cs` — and the view model is deliberately silent
|
||||
when neither is there, because there is no status line on this screen to say it on.
|
||||
|
||||
### 1.12 Something is on screen while it starts
|
||||
|
||||
Close DodoSSH and start it again. Watch the first second.
|
||||
|
||||
**Pass:** a small panel — about 420 pixels wide, no titlebar — appears saying **DodoSSH** and *Starting…*,
|
||||
and is then replaced by the main window. Measured on a debug build against a cold
|
||||
profile: the panel at 668 ms, the window at 3.1 s. A release install is quicker and the shape is the same.
|
||||
What must never come back is the old behaviour, which was nothing at all on screen for the whole of it.
|
||||
|
||||
**Pass, and worth checking separately:** the panel is **gone** once the window is up, there is never a moment
|
||||
with two windows both claiming to be DodoSSH, and clicking another application while the panel is up puts
|
||||
that application in front of it. The last one is deliberate — a splash that stays on top is a splash sitting
|
||||
over whatever you gave up and went back to.
|
||||
|
||||
**And the failure state, which is the part with something to get wrong.** It needs provoking: put a
|
||||
*directory* where `cache.db` should be — `%LOCALAPPDATA%\DodoSSH.Nightly\cache.db` for the nightly — and
|
||||
start the application.
|
||||
|
||||
**Pass:** the panel stays and the line becomes *DodoSSH could not start:* followed by the reason and
|
||||
*Press Escape to close.* Escape closes it. Remove the directory afterwards.
|
||||
|
||||
There is deliberately nothing else on the card to contradict that line — the progress bar this check used to
|
||||
describe was taken off in "Take the frozen bar off the splash, and leave the line that is true", because an
|
||||
indeterminate bar over a composition that had stopped was the one thing a splash must never do.
|
||||
|
||||
**Failure means:** a panel left saying *Starting…* is the main window having failed to show with nothing
|
||||
catching it, which is the one outcome worse than the crash it replaced — `DodoSshApp.ComposeBehind` exists to
|
||||
make it impossible. Two windows at once is the show-then-close order in that method having been swapped:
|
||||
closing the only open window ends the process, so the real window is shown while the panel is still up.
|
||||
Nothing appearing at all means composition is running in the same dispatcher turn that has to paint the
|
||||
panel, which is what posting it at `DispatcherPriority.Background` prevents.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Known Hosts as its own page
|
||||
@@ -360,6 +485,74 @@ machine. That is the ordinary case rather than an error.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Importing from Hetzner Cloud
|
||||
|
||||
The reader has its own suite over the shapes the API returns, and the end-to-end path is covered by
|
||||
`ImportingFromHetzner_ShowsTheProjectFirstAndThenStoresWhatWasTicked` against a stubbed transport. What no
|
||||
test in this repository does is talk to Hetzner — there is no account, no token and no network in the
|
||||
ordinary suite — so everything below needs a real project.
|
||||
|
||||
### 2.11a A read-only token lists your project
|
||||
|
||||
Make a token in the Hetzner console under **Security → API tokens** with **Read** permission only. Settings
|
||||
→ Preferences → **OPEN WIZARD**, paste it, press **READ THE PROJECT**.
|
||||
|
||||
**Pass:** every server in that project is listed, with the address, the plan and the location the Hetzner
|
||||
console shows for it. The token box is **empty** afterwards — it is used for one request and dropped — and
|
||||
the Hosts screen is unchanged, because reading writes nothing.
|
||||
|
||||
**What to look for specifically:**
|
||||
- A project with more than fifty servers must list all of them. Fifty is Hetzner's page size, and a list
|
||||
that stops there exactly is the paginator not being followed.
|
||||
- A server with no public IPv4 should show the first address of its `/64` with a warning chip saying so.
|
||||
Check that address against what the server actually answers on — the convention is Hetzner's own images,
|
||||
not a fact the API states, and a machine whose operator moved it elsewhere in the `/64` will not answer.
|
||||
- A server the console shows as **off** should still be listed, with `off` in the STATE column.
|
||||
|
||||
**Failure means:** the importer and the console disagree about what the project holds, which produces
|
||||
bookmarks that dial the wrong machine. Worse than an import that refused.
|
||||
|
||||
### 2.11b A wrong token says so, and says where tokens come from
|
||||
|
||||
Paste anything that is not a valid token — a truncated one is the realistic case, since the console shows
|
||||
the value once.
|
||||
|
||||
**Pass:** the wizard stays on the token step, the message names the Hetzner console and the Security → API
|
||||
tokens page, and the box is cleared for a fresh paste. Nothing has been stored.
|
||||
|
||||
Now try a token from a **different** project.
|
||||
|
||||
**Pass:** it lists that project instead. A token belongs to one project and there is no API call that will
|
||||
say which — an account with several projects runs this once per project, which the note above the box says.
|
||||
|
||||
### 2.11c The private-network choice does not fall back
|
||||
|
||||
With a project holding at least one server on a private network and one on none, press **PRIVATE NETWORK**.
|
||||
|
||||
**Pass:** the server with no private network shows an em dash for its address, a chip reading *no address of
|
||||
that kind*, and a tickbox that cannot be ticked — including by the tick-all box in the header. The other
|
||||
one shows its `10.x` address.
|
||||
|
||||
**Failure means:** a public address quietly stored under a choice that asked to stay off the internet. This
|
||||
is the one asymmetry in how addresses resolve and it is deliberate; v4 and v6 fall back to each other,
|
||||
private does not.
|
||||
|
||||
### 2.11d Imported hosts are correct, and ask for a password
|
||||
|
||||
Import a couple, then open one on the Hosts screen.
|
||||
|
||||
**Pass:** the address, port and login user are what the wizard showed, and the notes carry the server id,
|
||||
the plan, the location and the image — and **not** the power state, which would be stale the moment somebody
|
||||
pressed a button in the console. Connecting asks for a **password**: no key came with these hosts, because
|
||||
Hetzner has never held the private half of one. Bind it to a key in your keychain and it connects.
|
||||
|
||||
Now go back to the list and import the same project again.
|
||||
|
||||
**Pass:** the ones already stored are marked *already here* and arrive unticked. Importing one twice is
|
||||
allowed and takes a click.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Drag and drop on the SFTP page
|
||||
|
||||
**This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native
|
||||
@@ -1687,6 +1880,27 @@ bottom and no scroll position left part way down.
|
||||
**Failure means:** the inset is being applied but not cleared — the closed state is not being read from the
|
||||
event, or the margin is only ever added to.
|
||||
|
||||
### 10.4a Nothing is stranded when the keyboard is still up on the way *out* · **the one 10.4 missed**
|
||||
|
||||
Focus any box, and with the keyboard still on screen leave the application — Home, or the task switcher,
|
||||
or anything that puts another app in front. Come back to it.
|
||||
|
||||
**Pass:** the interface fills the screen. Do it from the terminal too, where the keypad and the shell should
|
||||
both be where they were.
|
||||
|
||||
**Failure means:** `PhoneShell` is not re-reading the inset on the way back in. This is a separate path from
|
||||
10.4 and it is the one that has actually shipped broken: Android takes the keyboard away when the activity
|
||||
stops and Avalonia's input pane raises no `StateChanged` for it, because the pane goes with the window
|
||||
rather than closing in front of one. So the last value applied was for a keyboard that was up, and the
|
||||
application returns holding a whole keyboard's height of empty canvas — measured at 838 of 2340 pixels on a
|
||||
Pixel 5, on every screen, and staying that way until something opens and closes the keyboard. See
|
||||
`PhoneShell.OnActivityResumed` and `PhoneEnvironment.Resumed`.
|
||||
|
||||
Worth doing in both directions: a resume that *should* keep the inset is the other half. Trigger a
|
||||
permission prompt or the biometric sheet with the keyboard up, dismiss it, and check the box being typed
|
||||
into is still clear of a keyboard that is genuinely still there — a fix that flattened the inset on every
|
||||
resume would pass the paragraph above and fail this one.
|
||||
|
||||
### 10.5 Rotating with the keyboard up
|
||||
|
||||
Focus a passphrase box, then turn the phone sideways.
|
||||
@@ -1840,12 +2054,22 @@ Now tap **Tab** on the accessory row, or **A+**, and type again.
|
||||
keyboard.
|
||||
|
||||
**Failure means:** the buttons on that row have become focusable again. An ordinary Avalonia button takes
|
||||
focus on tap, which takes it off the `NativeWebView` — and the package's own `OnLostFocus` then resigns the
|
||||
page's focus. The row goes on working, because its keys are pressed rather than typed into, so the symptom
|
||||
is a terminal that answers the buttons and ignores the keyboard: it reads as the session having died. See
|
||||
`Focusable = false` in `TerminalScreen.axaml.cs` and the focus entry in `docs/platform-flags.md`.
|
||||
focus on tap, which takes it off `TerminalInputSurface`. The row goes on working, because its keys are
|
||||
pressed rather than typed into, so the symptom is a terminal that answers the buttons and ignores the
|
||||
keyboard: it reads as the session having died. See `Focusable = false` in `TerminalScreen.axaml.cs`.
|
||||
|
||||
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
|
||||
**Also worth typing while a hardware keyboard is attached**, because that path moved under
|
||||
[ADR 0015](adr/0015-android-terminal-input.md) and is the one most likely to have been broken by the move:
|
||||
it used to arrive through the `WebView`'s own key handling and now arrives through Avalonia's. Confirm that
|
||||
Ctrl-C interrupts **and leaves no stray `c` at the prompt** — Avalonia raises the letter's own text event
|
||||
whether or not the key event was handled, so a doubled character here means `controlByteAlreadySent` is not
|
||||
doing its job — that the arrow keys reach shell history, that Backspace deletes one character, and that Tab
|
||||
completes a path. **Forward-delete is a known, deliberate loss** — the Delete key sends a backspace,
|
||||
because Avalonia turns an IME's `deleteSurroundingText` into a `KEYCODE_FORWARD_DEL` and a broken backspace
|
||||
on every phone is worse than a missing forward-delete on the few devices with the key. See
|
||||
`TerminalInputSurface.TryTranslate`.
|
||||
|
||||
Worth doing on the software keyboard too, where the old fault showed as the keyboard closing on the first
|
||||
tap of an arrow key.
|
||||
|
||||
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
|
||||
@@ -1853,18 +2077,98 @@ tap of an arrow key.
|
||||
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
|
||||
keep typing on the software keyboard.
|
||||
|
||||
**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
|
||||
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
|
||||
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
|
||||
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
|
||||
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
|
||||
**Pass:** the keyboard settles back unchanged — same layout, same height — and everything typed after the
|
||||
tap still reaches the terminal. The accessory row stays visible above the keyboard throughout. There should
|
||||
be **no blink at all** now: under [ADR 0015](adr/0015-android-terminal-input.md) the input target is an
|
||||
Avalonia control, so the focus the platform hands to Avalonia's own view after every dispatched touch is
|
||||
already the focus the keyboard wants, and there is nothing to hand back.
|
||||
|
||||
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
|
||||
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
|
||||
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
|
||||
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
|
||||
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
|
||||
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
|
||||
**Failure means:** the buttons on that row have become focusable again, taking Avalonia's focus off
|
||||
`TerminalInputSurface`. The old failure — a keyboard that swapped layouts and parked itself over the row —
|
||||
belonged to the arrangement where the `WebView` held the input connection and is not reachable from here;
|
||||
if it reappears, the renderer has taken focus back and `RendererFocus.Suppress` did not run. See check
|
||||
11.10c.
|
||||
|
||||
### 11.10b The keyboard over a terminal is a terminal's keyboard · **the important one for ADR 0015**
|
||||
|
||||
Open a shell and look at the keyboard, without typing anything yet. Then type a few words of ordinary
|
||||
English — `ls`, `cd etc`, `sudo apt update`.
|
||||
|
||||
**Pass:** there is **no suggestion strip** above the keys, nothing is autocapitalised at the start of a line,
|
||||
no word is autocorrected, and no predictive text appears ahead of the cursor. Backspace deletes exactly one
|
||||
character per press. Rotating to landscape does not replace the terminal with a full-screen text editor.
|
||||
|
||||
**Failure means:** the premise of ADR 0015 is wrong and everything staged on it needs rethinking, so find
|
||||
out *which* half failed before concluding anything. Either `TextInputOptions.ShowSuggestions` is not
|
||||
reaching Avalonia's `AndroidInputMethod` — the guard there is narrow and needs `InputType` to be exactly
|
||||
`TYPE_CLASS_TEXT`, so anything that sets `ContentType`, `Multiline` or `AutoCapitalization` on
|
||||
`TerminalInputSurface` or an ancestor silently disables it, `inherits: true` being the trap — or the
|
||||
keyboard is ignoring `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD`, which would mean no Avalonia-side fix exists
|
||||
and the honest next step is a native input connection. Worth repeating on a second keyboard (Gboard and one
|
||||
other) before deciding, because this is a claim about keyboards rather than about Android.
|
||||
|
||||
### 11.10c The renderer cannot take the keyboard, and a tap still brings it back
|
||||
|
||||
With a shell open, put the keyboard away with the system back gesture. Then tap the terminal. Separately:
|
||||
with the keyboard up, drag the terminal to scroll its scrollback.
|
||||
|
||||
**Pass:** the tap brings the keyboard straight back. The drag scrolls the scrollback and does **not** raise
|
||||
the keyboard. At no point does a text caret or a selection handle appear inside the terminal.
|
||||
|
||||
**Failure means:** if the tap does nothing, `RendererFocus.Suppress` never found the `WebView` — it is
|
||||
called from two places, the posted `EnvironmentRequested` handler and the `IsTerminalShowing` transition,
|
||||
and a miss in both leaves no tap listener. If the drag raises the keyboard, the touch-slop test is not
|
||||
working. If a caret appears, the `WebView` is still focusable and the page has taken the input connection
|
||||
back, which is the whole failure ADR 0015 exists to prevent.
|
||||
|
||||
### 11.10d The Ctrl latch reaches the software keyboard
|
||||
|
||||
With a shell open and something running that can be interrupted — `sleep 60`, or `top` — tap **ctrl** on the
|
||||
accessory row and then press **c** on the software keyboard.
|
||||
|
||||
**Pass:** the process is interrupted, and the ctrl key returns to its unlatched colour.
|
||||
|
||||
**Failure means:** typed characters are not going through `TerminalScreen.SendAsync`. Note that this check
|
||||
could not pass *before* ADR 0015 and is not a regression test but a new capability: the latch used to apply
|
||||
only to the accessory row's own keys, because everything typed went straight into the page and never passed
|
||||
the code that holds the latch.
|
||||
|
||||
### 11.10e Enter runs the command and the keyboard stays where it is
|
||||
|
||||
With a shell open and the software keyboard up, type `pwd` and press Enter. Then type another command.
|
||||
|
||||
**Pass:** the command runs and **the keyboard is still up**, unmoved, ready for the next one. The Enter key
|
||||
reads as a return arrow rather than as `DONE` or a tick. Ten commands in a row cost ten Enters and no taps
|
||||
to bring the keyboard back.
|
||||
|
||||
**Failure means:** `TextInputOptions.ReturnKeyType` is not `Return` on `TerminalInputSurface`, so Avalonia
|
||||
has mapped the field's default to `IME_ACTION_DONE` and the keyboard is doing exactly what Done means. This
|
||||
was the first defect the first device run found. **Do not fix it with `Multiline = true`** — that reaches
|
||||
the same `IME_ACTION_NONE` by a route that ORs `TYPE_TEXT_FLAG_MULTI_LINE` into `InputType`, which fails the
|
||||
`ShowSuggestions` guard and hands the suggestion strip back; check 11.10b would then start failing and the
|
||||
cause would look unrelated. See the correction in [ADR 0015](adr/0015-android-terminal-input.md).
|
||||
|
||||
Worth pressing Enter on an empty prompt a few times too: each one should give a fresh prompt and nothing
|
||||
else. A doubled prompt per press means a line ending is arriving as both CR and LF.
|
||||
|
||||
### 11.10f Every key on the keypad is there, and pressing one presses it
|
||||
|
||||
With a shell open, look at the two rows above the keyboard without touching anything: **esc tab ctrl | - ~ /**
|
||||
on the upper row and **← ↓ ↑ →** with **A−** and **A+** on the lower one. All thirteen, at once, on a 360dp
|
||||
phone in portrait.
|
||||
|
||||
Then press them with a thumb rather than a fingertip, at an angle, twenty or so times across both rows —
|
||||
including the keys at the far left and far right ends.
|
||||
|
||||
**Pass:** every press registers, the key darkens under the thumb while it is down, and **nothing slides
|
||||
sideways**. The row is exactly where it was after twenty presses as it was before the first.
|
||||
|
||||
**Failure means:** a key that misses is a target that has got smaller — `Button.hotkey` is 40 tall for a
|
||||
reason and Android asks for 48. A row that *moves* is the one that matters: it means something has put a
|
||||
`ScrollViewer` back around the keys. That is how this row shipped originally and it does not survive contact
|
||||
with a thumb — a pan and a press begin with the same few pixels of travel, the scroller claims them, and the
|
||||
press is never delivered. If the set of keys outgrows the width, add a third row; do not make it scroll.
|
||||
See the keypad's own remark in `TerminalScreen.axaml`.
|
||||
|
||||
### 11.11 Closing a connection and opening a new one both take you somewhere real
|
||||
|
||||
@@ -1922,6 +2226,26 @@ without either end noticing, which a suspended renderer can leave behind — sho
|
||||
connected and swallows what is typed. If that is ever seen, it is a different bug from this one and needs a
|
||||
liveness probe rather than a faster retry.
|
||||
|
||||
### 11.13 The right button on a phone with a pointer · **needs a mouse, a trackpad or a dock**
|
||||
|
||||
The same page as 1.11 runs on this head, so the gesture is here too — but only for a device that has a
|
||||
secondary click at all. Attach a mouse or use a keyboard case's trackpad, or dock the phone, open a shell,
|
||||
select some output and right-click it; then right-click again with nothing selected.
|
||||
|
||||
**Pass:** exactly as 1.11 — the selection drops with no menu, and the second click pastes.
|
||||
|
||||
**Unverified, and that is why this check exists.** Nothing here has been run against Android's WebView with
|
||||
a pointer attached, so whether it delivers a secondary click as a DOM `contextmenu` event at all is an open
|
||||
question. `ClipboardReader` is wired on this head regardless, because the alternative is a head where the
|
||||
paste half silently does nothing and nobody finds out.
|
||||
|
||||
**Failure means:** if the copy works and the paste does not, the clipboard read is being refused — Android
|
||||
returns nothing from the clipboard to an app that is not the foreground one, and a WebView taking the
|
||||
secondary click may be leaving Avalonia's window in a state where `TopLevel` has none. If neither works,
|
||||
Android is not delivering the click as a `contextmenu` event, and the gesture needs a native listener on the
|
||||
`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer
|
||||
has no right button to miss.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Shared vaults: the operations that span two accounts
|
||||
@@ -2565,6 +2889,32 @@ having been broken, and the fallback is to move the offer into the titlebar inst
|
||||
the swap having been blocked, usually by a process still holding a file under `current\`. Being asked to
|
||||
enrol again means the profile directory did not survive, which is 16.1's failure arriving late.
|
||||
|
||||
### 16.7a Two releases published while it is running cost one update · **needs two releases**
|
||||
|
||||
The nightly channel is the easier place to do this — every push to `main` publishes one — but it works the
|
||||
same way on the release channel with two tags. With the client running and the banner already up for one
|
||||
build, publish a second. Then press **RESTART NOW** on the banner.
|
||||
|
||||
**Pass:** one download and one restart, landing on the **second** build. The banner says
|
||||
*"A newer build appeared. Fetching it before restarting"* with a percentage while it does, and it stays on
|
||||
screen throughout rather than vanishing. Starting up afterwards offers nothing, because there is nothing
|
||||
left to offer.
|
||||
|
||||
**And the timer's half, if you have six hours or a debugger.** Leave the client running past a check
|
||||
interval with a build already fetched and a newer one published. The banner's version changes on its own,
|
||||
and LATER having been pressed does not stop it coming back — a different version is a different question.
|
||||
|
||||
**Failure means:** two downloads and two restarts is the regression this check exists for, and it was the
|
||||
shipped behaviour: the background pass skipped itself entirely once anything was fetched, so a running
|
||||
client was pinned to whichever build it first saw and installed that, then found the next one at the launch
|
||||
straight afterwards. On a rolling channel — where the release is deleted and remade on every push, so the
|
||||
feed holds exactly one build — that was the normal case rather than a corner.
|
||||
|
||||
A banner that disappears when RESTART NOW is pressed is `IsRestarting` not reaching it; everything the strip
|
||||
draws keys off `IsReady`, and the last look moves the state off that. A restart that does not happen at all
|
||||
when the forge is unreachable is the last look having become a gate: it is a courtesy, and the build already
|
||||
on disk is still newer than the one running.
|
||||
|
||||
### 16.8 The first connect after an update is not a cold start
|
||||
|
||||
Immediately after 16.7, connect to a host.
|
||||
@@ -2610,6 +2960,43 @@ offer a nightly, ever, however many have been published since. It reads a differ
|
||||
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
|
||||
rather than as a bug in the nightly.
|
||||
|
||||
### 16.11 One instance, and what a second launch does instead
|
||||
|
||||
With DodoSSH running, start it again — Start menu, desktop shortcut, or the executable directly. Do it three
|
||||
times: with the window in front, with it minimised, and with it behind another application.
|
||||
|
||||
**Pass:** no second window appears, and the one already open comes forward, un-minimised if it was
|
||||
minimised. **A flashing taskbar button rather than a raised window is also a pass**: Windows refuses the
|
||||
foreground to a process that did not receive the input, which is its own rule about focus stealing and not
|
||||
something to defeat. Nothing happening at all is the failure.
|
||||
|
||||
**And the side-by-side case, which is the whole reason the lock lives where it does.** 16.10's arrangement,
|
||||
both builds installed, both started.
|
||||
|
||||
**Pass:** both run. They are separate applications with separate profiles, and the lock is a file inside the
|
||||
profile — so neither can see the other's. Two people signed in to one machine is the same property from the
|
||||
other direction and needs no check of its own.
|
||||
|
||||
**And the update relaunch · needs a release.** Take an update (16.7) and press RESTART NOW.
|
||||
|
||||
**Pass:** the application comes back. This is the race worth naming: the updater starts the new build as the
|
||||
old one is exiting, and a lock held a moment too long would make the new process refuse itself and disappear
|
||||
— an update that looked like an uninstall. `SingleInstance.Claim` keeps trying for three seconds while the
|
||||
lock is held by something that does not answer its pipe, which is what covers it.
|
||||
|
||||
**Failure means:** a second window means the lock is not being taken. A profile directory that cannot be
|
||||
written to answers "you are the only one" on purpose — refusing to start an SSH client over a permissions
|
||||
problem would be the wrong trade — so check that the profile exists, is writable, and has an
|
||||
`instance.lock` in it while the application runs. A second launch that exits without raising the first means
|
||||
the lock worked and the doorbell did not: the pipe name is derived from the profile path, so two processes
|
||||
disagreeing about it means they disagree about which profile they are in. Two windows *after an update* is
|
||||
the opposite failure — the outgoing process never released the lock, which means something is holding it
|
||||
open past `Shutdown`.
|
||||
|
||||
`SingleInstanceTests` covers everything reachable without a second process: the lock, its release, the
|
||||
doorbell answering more than once, and two profiles not excluding each other. What is left for a person is a
|
||||
second *executable* and a real window coming forward.
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — Installing and updating the phone
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
# Reaching a host you cannot dial
|
||||
|
||||
Some machines do not answer from where the user is sitting. This product has **two** answers to that, and
|
||||
neither of them works.
|
||||
one of them works.
|
||||
|
||||
- The **relay** pipes raw TCP through the deployment. The server half is built and shipped; the client
|
||||
half does not exist, and both heads offer a checkbox that promises it.
|
||||
- The **relay** pipes raw TCP through the deployment. Both halves are built: the server issues a ticket and
|
||||
pipes a WebSocket to the host, and the client reaches it through a loopback SOCKS5 bridge. Step 3 below.
|
||||
- A **jump host** reaches the target through a machine already in the keychain. `HostSecret.JumpHostIds`
|
||||
stores the chain; nothing writes it and nothing reads it.
|
||||
|
||||
◆ **An earlier version of this page said the relay's server half was "built and shipped". It was not.** What
|
||||
existed was ADR 0004, the contract records, the options and the CHECK constraint — and `RelayOptions` said,
|
||||
in its own remarks, that there was no endpoint and no dialler. Both halves were built together at step 3.
|
||||
|
||||
This document is the comparison between them, which is the thing that has to be settled before either is
|
||||
built, and then the plan. It replaces an earlier draft of `docs/jump-hosts.md` that recommended deleting the
|
||||
jump chain — see the last section for why that was wrong.
|
||||
|
||||
> **Status: step 0 done, the rest planned.**
|
||||
> **Status: steps 0, 1 and 3 done, and step 4 for the relay. Jump hosts are next.**
|
||||
>
|
||||
> | Step | State | Notes |
|
||||
> | --- | --- | --- |
|
||||
> | 0. Stop promising the relay | **Done** | Both heads say the box is not wired up and that ticking it stores the address and changes nothing. Left tickable, so a host already carrying the flag can lose it |
|
||||
> | 0. Stop promising the relay | **Done, then retired at step 3** | The "not wired up yet" sentence came out of both heads when the relay started working |
|
||||
> | 1. Dial through a loopback proxy | **Done** | `SshLoopbackProxy` on the request, honoured in `BuildConnectionInfo`. **Not the bridge** — see below |
|
||||
> | 2. Jump hosts over it | Not started | No server change, and no bridge either: SSH.NET's own forward is the proxy |
|
||||
> | 3. The relay: the bridge, then the ticket | Not started | The bridge's only consumer, so it lands with the feature that uses it |
|
||||
> | 4. File transfer parity | Not started | The transfers screen opens its own connection. Already half-done — `OpenSftpAsync` shares `BuildConnectionInfo`, so it honours a proxy today; what is missing is anything passing it one |
|
||||
> | 3. The relay: the bridge, then the ticket | **Done** | Server: `Features/Relay`. Client: `RelayBridge`, `ServerRelay`, and `VaultViewModel.OpenThroughAsync`. See "What step 3 built" |
|
||||
> | 4. File transfer parity | **Done for the relay** | `TransfersViewModel` opens a relay for a relayed host exactly as the shell does. A jump chain will need the same line when step 2 lands |
|
||||
|
||||
## ◆ The relay's checkbox is a false promise, and that is a defect
|
||||
|
||||
@@ -171,7 +175,64 @@ the sentence step 0 added comes out of both heads.
|
||||
|
||||
**4. File transfer.** `ISftpSessionFactory.OpenSftpAsync` opens its own second connection, so a host that
|
||||
needs a chain or a relay to reach needs it there too, or SFTP silently fails for exactly the hosts this
|
||||
work exists for.
|
||||
work exists for. ✅ for the relay.
|
||||
|
||||
## What step 3 built
|
||||
|
||||
**The server**, in `DodoSSH.Api/Features/Relay`:
|
||||
|
||||
- `POST /api/v1/relay/tickets` makes every decision. It finds the host, requires Connect on its vault and
|
||||
the relay switched on, resolves the stored name, and runs every answer through `RelayAddressPolicy` —
|
||||
the non-overridable list first, then the operator's denials, allow-list and private-network switch. What
|
||||
passes goes into a Data Protection ticket; the name is not resolved again. **Every target refusal is one
|
||||
answer**, 403 `relay-target-rejected` with one sentence, because anybody who can write a host can point
|
||||
it anywhere, and a distinct "resolves, but refused" would be a scanner for the operator's DNS. The real
|
||||
reason is logged (event 2301).
|
||||
- `GET /api/v1/relay/connect` is anonymous at the HTTP layer and takes the ticket as the second
|
||||
subprotocol. It verifies, checks expiry against `TimeProvider`, and spends the ticket with a unique
|
||||
insert into `relay_ticket_use` before accepting the upgrade — so a refused ticket is a 401 and no socket.
|
||||
After the upgrade, a refusal the client could not have avoided is a close code: 4429 for a limit, 4502
|
||||
for a host the server could not reach, 4408 idle, 4409 lifetime, 1001 at the end of a shutdown's drain.
|
||||
- `relay_session` is the audit row: opened before the dial, closed after, each from a scope of its own.
|
||||
|
||||
**The client:**
|
||||
|
||||
- `RelayBridge` (Client.Ssh) is the loopback SOCKS5 listener. It binds `127.0.0.1`, accepts once, answers
|
||||
one CONNECT without acting on its target, and pipes to the WebSocket. It is handed to the connection as
|
||||
the new `SshLoopbackProxy.Lifetime`, so the connection disposes it on every way a session ends, and a
|
||||
failed connect disposes it before throwing.
|
||||
- `ServerRelay` (Client.Session) asks for the ticket, opens the socket and starts the bridge, and turns
|
||||
every refusal into a sentence. It is `IVaultServer.Relay`, decided from `/api/v1/meta` the way `Events`
|
||||
is; a server without the feature supplies `UnavailableServerRelay`.
|
||||
- `VaultViewModel` opens a relay for a host with the box ticked and **never falls back to dialling it
|
||||
directly** — the user ticked it because that path does not work, or goes somewhere they do not mean. A
|
||||
relay that closed with a reason replaces SSH.NET's transport error with that reason, and the
|
||||
reachability probe is skipped for a relayed host, because it would test the direct path.
|
||||
|
||||
### ◆ Two things that are not what ADR 0004 wrote
|
||||
|
||||
- **No `System.IO.Pipelines`.** Each direction reads one buffer and does not read again until the write
|
||||
has completed. That is the same end-to-end backpressure — a slow peer stops the read, which fills the
|
||||
kernel's window, which throttles the far sender — with a tighter bound: two 32 KiB buffers a session
|
||||
rather than ~2 MiB.
|
||||
- **No `?ticket=` fallback, and no device id in the ticket.** The fallback exists for clients that cannot
|
||||
set a subprotocol, which neither head is, and it would put a bearer credential in the one place — the
|
||||
URL — that access logs keep. A device is never visible to the server on an ordinary request.
|
||||
|
||||
### ◆ A WebSocket operation is never cancelled, on either side
|
||||
|
||||
Cancelling a pending receive or send on a .NET WebSocket aborts it, and an aborted socket sends no close
|
||||
frame. The close frame is the only way the client learns that the server could not reach the host, so both
|
||||
the server's pump and the client's bridge stop a direction by closing and waiting a bounded time instead.
|
||||
`AnIdleSession_IsClosedWithTheIdleCode` holds the server to it: it asserts the code arrives, not merely that
|
||||
the socket ended.
|
||||
|
||||
### What a developer meets
|
||||
|
||||
The relay refuses loopback with no configuration escape, so the development stack's sshd on
|
||||
`localhost:2222` cannot be reached through it. Point a relayed host at an address the API process reaches
|
||||
on a real interface — the machine's own LAN address works — or at the container's address on the Docker
|
||||
network.
|
||||
|
||||
## Traps already known
|
||||
|
||||
@@ -226,8 +287,8 @@ is right, sharing a lifetime policy is not.
|
||||
row, and the status bar's `via bastion-eu` row, all of which say jump hosts are data-only.
|
||||
- `Client.Import/ImportedHost.cs` — the note written onto every imported host with a `ProxyJump`, and the
|
||||
remark above it.
|
||||
- `README.md` and `docs/android-port.md` wherever the relay is described as available.
|
||||
- ADR 0004 gains a note that its last consequence was built, and how.
|
||||
- `README.md` and `docs/android-port.md` wherever the relay is described as available. ✅ at step 3.
|
||||
- ADR 0004 gains a note that its last consequence was built, and how. ✅ at step 3.
|
||||
|
||||
## What the first draft of this document got wrong
|
||||
|
||||
|
||||
+24
-5
@@ -223,6 +223,25 @@ echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
|
||||
#
|
||||
# Everything above can succeed and still produce something nobody can install, and none of the ways
|
||||
# that happens are visible in an exit code. Each check below is one of them.
|
||||
#
|
||||
# ◆ EVERY READER IN A PIPELINE HERE MUST CONSUME ALL OF ITS INPUT, AND THAT IS NOT A STYLE RULE.
|
||||
#
|
||||
# `set -o pipefail` at the top of this file means a pipeline's status is the rightmost non-zero one.
|
||||
# A reader that stops early — `grep -q`, `head -n` — closes the pipe while the writer is still
|
||||
# writing, the writer dies of SIGPIPE with status 141, and pipefail hands that 141 to the `if`. So a
|
||||
# check that found what it was looking for reports the opposite, and a diagnostic printed on the way
|
||||
# to `exit 1` kills the script under `set -e` before it gets there.
|
||||
#
|
||||
# It is a threshold rather than a constant, which is what made it look like somebody else's bug: the
|
||||
# writer only blocks once it has more than a pipe buffer left to write, about 64 KB on Linux. `$files`
|
||||
# is one line per packaged file, so this passed for as long as the published tree was small and then
|
||||
# failed on the commit that pushed the path text past 64 KB — reporting `/opt/DodoSSH/DodoSSH` missing
|
||||
# from a package that plainly contained it, and exiting 141 rather than the 1 it meant to.
|
||||
#
|
||||
# Hence `grep pattern >/dev/null` rather than `grep -q pattern`, which has the same exit status and
|
||||
# reads to the end; and `awk 'NR<=40'` rather than `head -40`, which prints the same lines and reads
|
||||
# to the end. Nothing here is allowed to stop early, and `|| true` is not the fix — it would hide a
|
||||
# real failure of the writer alongside the imaginary one.
|
||||
|
||||
requires="$(rpm -qp --requires "$rpm_file")"
|
||||
provides="$(rpm -qp --provides "$rpm_file")"
|
||||
@@ -236,7 +255,7 @@ files="$(rpm -qp --list "$rpm_file")"
|
||||
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
|
||||
unsatisfiable=""
|
||||
for soname in $carried; do
|
||||
if printf '%s\n' "$requires" | grep -qF "$soname("; then
|
||||
if printf '%s\n' "$requires" | grep -F "$soname(" >/dev/null; then
|
||||
unsatisfiable="$unsatisfiable $soname"
|
||||
fi
|
||||
done
|
||||
@@ -251,7 +270,7 @@ fi
|
||||
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
|
||||
# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring
|
||||
# the macro would break it silently and in the direction nobody looks.
|
||||
if printf '%s\n' "$provides" | grep -qE '^lib.*\.so'; then
|
||||
if printf '%s\n' "$provides" | grep -E '^lib.*\.so' >/dev/null; then
|
||||
echo "This package advertises bundled libraries to the system:" >&2
|
||||
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2
|
||||
echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2
|
||||
@@ -262,7 +281,7 @@ fi
|
||||
# without libelf would do exactly that — the package would carry no glibc requirement at all and
|
||||
# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a
|
||||
# self-contained .NET tree cannot fail to have.
|
||||
if ! printf '%s\n' "$requires" | grep -q '^libc\.so\.6'; then
|
||||
if ! printf '%s\n' "$requires" | grep '^libc\.so\.6' >/dev/null; then
|
||||
echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2
|
||||
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
|
||||
printf '%s\n' "$requires" >&2
|
||||
@@ -279,9 +298,9 @@ for expected in \
|
||||
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
||||
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
||||
do
|
||||
if ! printf '%s\n' "$files" | grep -qxF "$expected"; then
|
||||
if ! printf '%s\n' "$files" | grep -xF "$expected" >/dev/null; then
|
||||
echo "The package was built and $expected is not in it." >&2
|
||||
printf '%s\n' "$files" | head -40 >&2
|
||||
printf '%s\n' "$files" | awk 'NR<=40' >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
+60
-10
@@ -169,16 +169,66 @@ Bump ApplicationVersion, commit, move the tag, and run this again.
|
||||
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
|
||||
New-Item -ItemType Directory -Path $Staging | Out-Null
|
||||
|
||||
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
|
||||
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
||||
& dotnet build $Project --no-restore --configuration Release `
|
||||
-t:SignAndroidPackage `
|
||||
-p:DodoChannel=release `
|
||||
-p:AndroidKeyStore=true `
|
||||
-p:AndroidSigningKeyStore=$KeystorePath `
|
||||
-p:AndroidSigningKeyAlias=$KeyAlias `
|
||||
-p:AndroidSigningStorePass=$storePlain `
|
||||
-p:AndroidSigningKeyPass=$keyPlain
|
||||
# ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE.
|
||||
#
|
||||
# They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is
|
||||
# not. A process's command line is not a secret on Windows: any process running as this user can read
|
||||
# every argument of every other with one call —
|
||||
#
|
||||
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||
#
|
||||
# — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The
|
||||
# comment above says the passwords are never stored, and with the arguments written that way it was
|
||||
# true of the disk and false of everything watching the machine.
|
||||
#
|
||||
# env: is .NET for Android's own answer to exactly this, and it names the variable rather than carrying
|
||||
# the value: MSBuild is handed the string "env:DODOSSH_ANDROID_STOREPASS" and reads the password out of
|
||||
# the environment itself. The one restriction is $(AndroidPackageFormat) = aab, where env: is not
|
||||
# supported and file: is the documented alternative; this channel ships an APK, and the guard below
|
||||
# fails loudly rather than silently signing with the literal string if that ever changes.
|
||||
#
|
||||
# Not a perfect channel — an environment block is readable by a process with rights over this one, the
|
||||
# same as memory is — but it is off the command line, out of the audit trail and out of any log that
|
||||
# echoes the build invocation, which is the whole of the difference being bought here.
|
||||
#
|
||||
# -nodeReuse:false so no MSBuild worker outlives this call holding the variables in its environment.
|
||||
# The finally clears them from this process either way, and a persistent node would have kept its own
|
||||
# inherited copy for the next quarter of an hour regardless of what happens here.
|
||||
$packageFormat = (& dotnet msbuild $Project -getProperty:AndroidPackageFormat -nologo `
|
||||
-p:DodoChannel=release | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
|
||||
if ($packageFormat -eq 'aab') {
|
||||
Stop-With @'
|
||||
This channel now builds an aab, and the env: prefix this script signs with is not supported for that
|
||||
format. The signing would silently take "env:DODOSSH_ANDROID_STOREPASS" for the password itself.
|
||||
|
||||
Switch both passwords to the file: prefix, which is supported for aab, and make sure whatever writes
|
||||
that file removes it again.
|
||||
'@
|
||||
}
|
||||
|
||||
try {
|
||||
$env:DODOSSH_ANDROID_STOREPASS = $storePlain
|
||||
$env:DODOSSH_ANDROID_KEYPASS = $keyPlain
|
||||
|
||||
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
|
||||
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
||||
& dotnet build $Project --no-restore --configuration Release `
|
||||
-nodeReuse:false `
|
||||
-t:SignAndroidPackage `
|
||||
-p:DodoChannel=release `
|
||||
-p:AndroidKeyStore=true `
|
||||
-p:AndroidSigningKeyStore=$KeystorePath `
|
||||
-p:AndroidSigningKeyAlias=$KeyAlias `
|
||||
-p:AndroidSigningStorePass=env:DODOSSH_ANDROID_STOREPASS `
|
||||
-p:AndroidSigningKeyPass=env:DODOSSH_ANDROID_KEYPASS
|
||||
}
|
||||
finally {
|
||||
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
|
||||
# and this runs on the failure path, where it may well not be.
|
||||
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_STOREPASS', $null)
|
||||
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_KEYPASS', $null)
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
|
||||
|
||||
|
||||
@@ -38,9 +38,11 @@
|
||||
# xcrun notarytool store-credentials DodoSSH \
|
||||
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
|
||||
#
|
||||
# The forge token is the one real secret, and it is prompted for rather than read from a file or the
|
||||
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
|
||||
# minutes a credential that can publish an update spends in a shell's memory the better.
|
||||
# The forge token is the one real secret, and it is prompted for rather than read from a file or an
|
||||
# ambient environment variable, and only in the phase that needs it — for the reason the Windows script
|
||||
# gives: the fewer minutes a credential that can publish an update spends in a shell's memory the better.
|
||||
# It reaches vpk through VPK_TOKEN, exported for that one call and unset afterwards, because the
|
||||
# alternative is --token on a command line that is not private. See the comment at the upload itself.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/release-macos.sh
|
||||
@@ -195,10 +197,18 @@ if [ "$UPLOAD" -eq 1 ]; then
|
||||
# also already hold the Windows package for the same tag, which is the case --merge is really doing
|
||||
# the work for: without it the second platform to publish a given version fails on a release that
|
||||
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
|
||||
#
|
||||
# No --token in this array, and that is deliberate. It used to be there, and an argument is not a
|
||||
# private place to put a credential: /proc/<pid>/cmdline is world-readable on Linux, `ps` hands out
|
||||
# other processes' arguments freely, and whatever agent is auditing process creation on this machine
|
||||
# keeps its own copy. The comment above is about shell history and was never the whole story.
|
||||
#
|
||||
# vpk documents VPK_TOKEN as --token's environment fallback, so the value never becomes an argument.
|
||||
# Exported for the one call and unset straight after; an environment block is still readable by a
|
||||
# process with rights over this one, but it is out of every process listing and audit log.
|
||||
UPLOAD_ARGS=(
|
||||
upload gitea
|
||||
--repoUrl "$REPO_URL"
|
||||
--token "$TOKEN"
|
||||
--outputDir "$RELEASES_DIR"
|
||||
--channel "$CHANNEL"
|
||||
--releaseName "$TAG"
|
||||
@@ -214,7 +224,15 @@ if [ "$UPLOAD" -eq 1 ]; then
|
||||
esac
|
||||
|
||||
write_step 'Uploading'
|
||||
dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'
|
||||
|
||||
# Unset on the way out whether or not the upload worked, so a failed release does not leave the token
|
||||
# sitting in this shell for whatever runs next.
|
||||
export VPK_TOKEN="$TOKEN"
|
||||
vpk_status=0
|
||||
dotnet vpk "${UPLOAD_ARGS[@]}" || vpk_status=$?
|
||||
unset VPK_TOKEN
|
||||
|
||||
[ "$vpk_status" -eq 0 ] || stop_with 'vpk upload failed.'
|
||||
|
||||
write_step "Published $TAG."
|
||||
exit 0
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
|
||||
so the bytes that reach users are the bytes that were installed and checked.
|
||||
|
||||
The token is prompted for rather than read from a file or an environment variable, and only in the phase
|
||||
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
|
||||
in a shell's memory the better.
|
||||
The token is prompted for rather than read from a file or an ambient environment variable, and only in
|
||||
the phase that needs it — the build does not, and the fewer minutes a credential that can publish an
|
||||
update spends in a shell's memory the better. It reaches vpk through VPK_TOKEN, set for that one call
|
||||
and cleared afterwards, because the alternative is --token on a command line every process on this
|
||||
machine can read. See the comment at the upload itself.
|
||||
|
||||
.PARAMETER Upload
|
||||
Publish the packages already in Releases/ instead of building.
|
||||
@@ -152,10 +154,24 @@ try {
|
||||
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
|
||||
# fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
|
||||
# same tag, so a release candidate is a prerelease in both channels or in neither.
|
||||
#
|
||||
# ◆ NO --token HERE. IT WAS AN ARGUMENT, AND AN ARGUMENT IS PUBLIC ON THIS MACHINE.
|
||||
#
|
||||
# The comment above says the token never lands in shell history, which was true and was not the
|
||||
# whole story: it went straight into vpk's command line, and any process running as this user can
|
||||
# read that off a running process with one call —
|
||||
#
|
||||
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||
#
|
||||
# — with process-creation auditing and EDR agents keeping their own copy afterwards. A token that
|
||||
# can publish an update every installed copy will fetch and run deserves better than that.
|
||||
#
|
||||
# vpk documents VPK_TOKEN as --token's environment fallback, so the flag simply goes away and the
|
||||
# value never becomes an argument at all. An environment block is still readable by a process with
|
||||
# rights over this one; what it is not is a string sitting in a process listing and an audit log.
|
||||
$uploadArgs = @(
|
||||
'upload', 'gitea',
|
||||
'--repoUrl', $RepoUrl,
|
||||
'--token', $token,
|
||||
'--outputDir', $ReleasesDir,
|
||||
'--channel', $Channel,
|
||||
'--releaseName', $tag,
|
||||
@@ -169,7 +185,17 @@ try {
|
||||
}
|
||||
|
||||
Write-Step 'Uploading'
|
||||
& dotnet vpk @uploadArgs
|
||||
|
||||
try {
|
||||
$env:VPK_TOKEN = $token
|
||||
& dotnet vpk @uploadArgs
|
||||
}
|
||||
finally {
|
||||
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
|
||||
# and this runs on the failure path, where it may well not be.
|
||||
[Environment]::SetEnvironmentVariable('VPK_TOKEN', $null)
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
|
||||
|
||||
Write-Step "Published $tag."
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using DodoSSH.Domain;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace DodoSSH.Api.Authorization;
|
||||
|
||||
/// <summary>Requires the caller's account to be <see cref="UserStatus.Active"/>.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="UserStatus.Suspended"/> is documented as "sign-in blocked", and nothing blocked it: the identity
|
||||
/// provider still issues tokens for the person, every endpoint resolved them through the current-user context,
|
||||
/// and that context looked an account up by issuer, subject and deletion alone. A suspended account could pull
|
||||
/// and push vaults, take relay tickets and hold the events socket open. This is the check, on both policies, so
|
||||
/// that no endpoint can be reached without it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A connection already open when the account is suspended — a relay tunnel, the events socket — is not
|
||||
/// reached by this; it ends on its own idle and lifetime limits.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ActiveAccountRequirement : IAuthorizationRequirement;
|
||||
|
||||
/// <summary>Satisfies <see cref="ActiveAccountRequirement"/> from the account row.</summary>
|
||||
internal sealed class ActiveAccountHandler(ICurrentUserContext currentUser, IHttpContextAccessor accessor)
|
||||
: AuthorizationHandler<ActiveAccountRequirement>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
ActiveAccountRequirement requirement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// The policy's authentication requirement already refuses an anonymous caller with a 401; failing this
|
||||
// as well would turn that into a 403.
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||
|
||||
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (user.Status == UserStatus.Active)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,8 +102,14 @@ internal sealed class CurrentUserContext(
|
||||
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
|
||||
/// requests from one user would each read the row, each set the timestamp, and the second would
|
||||
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
|
||||
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
|
||||
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
|
||||
/// <para>
|
||||
/// ◆ <b>But the write moves xmin under the tracked entity, so the entity is re-read after it.</b> It is
|
||||
/// not marked modified — that would enlist the user row in whatever the request saves next — and a
|
||||
/// stale timestamp in memory would change nothing on its own. A stale concurrency token does: the next
|
||||
/// save in the same request that touches this user (enrolment stamps <c>EnrolledAtUtc</c>) compared
|
||||
/// against the version from before the update and failed, so enrolling more than an hour after first
|
||||
/// signing in answered 500. Only when a row was actually written, which is once an hour at most.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -114,13 +120,18 @@ internal sealed class CurrentUserContext(
|
||||
return;
|
||||
}
|
||||
|
||||
await database.Users
|
||||
var written = await database.Users
|
||||
.Where(u => u.Id == user.Id
|
||||
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (written > 0)
|
||||
{
|
||||
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
||||
|
||||
@@ -80,6 +80,26 @@ internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareR
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(authorizeResult);
|
||||
|
||||
var accountNotActive = authorizeResult.Forbidden
|
||||
&& authorizeResult.AuthorizationFailure is { } refusal
|
||||
&& refusal.FailedRequirements.OfType<ActiveAccountRequirement>().Any();
|
||||
|
||||
if (accountNotActive)
|
||||
{
|
||||
await TypedResults.Problem(
|
||||
detail: "This account has been suspended. Ask whoever runs this DodoSSH server.",
|
||||
statusCode: StatusCodes.Status403Forbidden,
|
||||
type: ProblemCodes.TypeBaseUri + ProblemCodes.AccountNotActive,
|
||||
extensions: new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["code"] = ProblemCodes.AccountNotActive,
|
||||
})
|
||||
.ExecuteAsync(context)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var enrollmentMissing = authorizeResult.Forbidden
|
||||
&& authorizeResult.AuthorizationFailure is { } failure
|
||||
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using DodoSSH.Api.Features.Relay;
|
||||
|
||||
namespace DodoSSH.Api.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Announces a change to everything on this node that acts on one: the event sockets, and the relay.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What the write paths are given as their <see cref="IVaultEventPublisher"/>. They announce a change once and
|
||||
/// do not know who listens, which is the point: the relay learning about removals from teams did not have to
|
||||
/// touch any of the places a removal happens.
|
||||
/// </remarks>
|
||||
internal sealed class VaultEventFanOut(VaultEventHub hub, RelayAccessWatch relay) : IVaultEventPublisher
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void VaultChanged(Guid vaultId, long sequence) => hub.VaultChanged(vaultId, sequence);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void VaultAccessChanged(Guid userId)
|
||||
{
|
||||
hub.VaultAccessChanged(userId);
|
||||
_ = relay.AccessChangedAsync(userId);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
using DodoSSH.Domain;
|
||||
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
|
||||
byte[] fingerprint,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
return await database.ExecuteFreshAsync([user], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
|
||||
@@ -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,62 @@
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Domain.Authorization;
|
||||
|
||||
namespace DodoSSH.Api.Features.Relay;
|
||||
|
||||
/// <summary>
|
||||
/// Ends a user's relay sessions to vaults they can no longer reach, when their access changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A relay session is authorised once, when its ticket is issued, and used to run on for as long as it
|
||||
/// carried bytes — up to the maximum lifetime, twelve hours by default. Removing somebody from a team took
|
||||
/// their keys and their vault list away at once, and left any shell they had open through the relay running
|
||||
/// into the team's machines until it went idle.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Told by the same announcement that tells their event socket to re-read its vaults — see
|
||||
/// <see cref="Events.IVaultEventPublisher.VaultAccessChanged"/> — and it asks the same question the ticket
|
||||
/// endpoint asked, against the database as it now stands. Off the announcing request, because that request
|
||||
/// has already committed and answered, and a slow re-check must not hold it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RelayAccessWatch(
|
||||
RelaySessionRegistry sessions,
|
||||
IServiceScopeFactory scopes,
|
||||
ILogger<RelayAccessWatch> logger)
|
||||
{
|
||||
/// <summary>Re-checks every session the user has open, in the background.</summary>
|
||||
/// <returns>The re-check, for a caller that wants to wait for it; the announcement does not.</returns>
|
||||
internal Task AccessChangedAsync(Guid userId)
|
||||
{
|
||||
var open = sessions.SessionsOf(userId);
|
||||
|
||||
return open.Count == 0 ? Task.CompletedTask : Task.Run(() => RecheckAsync(userId, open));
|
||||
}
|
||||
|
||||
private async Task RecheckAsync(Guid userId, IReadOnlyList<RelaySessionRegistry.Lease> open)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scope = scopes.CreateAsyncScope();
|
||||
await using var _ = scope.ConfigureAwait(false);
|
||||
|
||||
var access = scope.ServiceProvider.GetRequiredService<IVaultAccessService>();
|
||||
|
||||
foreach (var lease in open)
|
||||
{
|
||||
var resolved = await access.ResolveAsync(userId, lease.VaultId, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!resolved.Granted || !resolved.Permissions.HasFlag(PermissionFlags.Connect))
|
||||
{
|
||||
lease.Revoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
RelayLog.AccessRecheckFailed(logger, userId, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,759 @@
|
||||
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, ticket.VaultId);
|
||||
|
||||
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, lease.Revoked, 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 revoked,
|
||||
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, revoked, 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 revoked,
|
||||
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, revoked, 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 revoked,
|
||||
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;
|
||||
}
|
||||
|
||||
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
|
||||
|
||||
if (Due(now, revoked.IsCancellationRequested, hardDeadline, drainDeadline, idleDeadline) is { } ending)
|
||||
{
|
||||
return ending;
|
||||
}
|
||||
|
||||
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, revoked, lifetime.ApplicationStopping)
|
||||
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The shutdown began, or access was withdrawn. Round again to act on whichever it was.
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Whichever reason to end the session has come due, or null while none has.</summary>
|
||||
/// <remarks>
|
||||
/// Revocation first, because it is the one reason here that is about the user rather than the session:
|
||||
/// they were removed from the team, or the vault was taken from them, after this session was admitted.
|
||||
/// It used to run on for as long as it carried bytes, up to the maximum lifetime.
|
||||
/// </remarks>
|
||||
private static Ending? Due(
|
||||
DateTimeOffset now,
|
||||
bool revoked,
|
||||
DateTimeOffset hardDeadline,
|
||||
DateTimeOffset? drainDeadline,
|
||||
DateTimeOffset idleDeadline)
|
||||
{
|
||||
if (revoked)
|
||||
{
|
||||
return AccessRevoked;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return now >= idleDeadline
|
||||
? new Ending(
|
||||
RelayCloseReason.IdleTimeout,
|
||||
RelayProtocol.IdleTimeoutCloseCode,
|
||||
"This relay session carried nothing for the idle timeout.")
|
||||
: null;
|
||||
}
|
||||
|
||||
private static Ending AccessRevoked { get; } = new(
|
||||
RelayCloseReason.AccessRevoked,
|
||||
RelayProtocol.AccessRevokedCloseCode,
|
||||
"You no longer have access to this host.");
|
||||
|
||||
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,104 @@
|
||||
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);
|
||||
|
||||
/// <remarks>
|
||||
/// A warning: the sessions it was checking stay open, which is how they all behaved before the check
|
||||
/// existed, and each still ends at its idle timeout or its lifetime.
|
||||
/// </remarks>
|
||||
[LoggerMessage(
|
||||
EventId = 2309,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Re-checking relay access for user {UserId} after an access change failed.")]
|
||||
internal static partial void AccessRecheckFailed(ILogger logger, Guid userId, 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,150 @@
|
||||
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 readonly HashSet<Lease> open = [];
|
||||
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>
|
||||
/// <summary>The sessions a user has open now, for re-checking their access.</summary>
|
||||
internal IReadOnlyList<Lease> SessionsOf(Guid userId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return [.. open.Where(lease => lease.UserId == userId)];
|
||||
}
|
||||
}
|
||||
|
||||
internal Lease? TryAdmit(Guid userId, Guid vaultId)
|
||||
{
|
||||
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++;
|
||||
|
||||
var lease = new Lease(this, userId, vaultId);
|
||||
open.Add(lease);
|
||||
|
||||
return lease;
|
||||
}
|
||||
}
|
||||
|
||||
private void Release(Lease lease, Guid userId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
open.Remove(lease);
|
||||
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, Guid vaultId) : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource revoked = new();
|
||||
|
||||
private int released;
|
||||
|
||||
/// <summary>Whose session this is.</summary>
|
||||
internal Guid UserId { get; } = userId;
|
||||
|
||||
/// <summary>The vault the ticket's host is filed in, which is what the session's access hangs on.</summary>
|
||||
internal Guid VaultId { get; } = vaultId;
|
||||
|
||||
/// <summary>Cancelled when the session's user loses access to <see cref="VaultId"/>.</summary>
|
||||
internal CancellationToken Revoked => revoked.Token;
|
||||
|
||||
/// <summary>Ends the session, because its user can no longer reach the vault it was opened for.</summary>
|
||||
internal void Revoke()
|
||||
{
|
||||
try
|
||||
{
|
||||
revoked.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already over.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref released, 1) == 0)
|
||||
{
|
||||
registry.Release(this, UserId);
|
||||
revoked.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -100,9 +100,7 @@ internal sealed class SyncService(
|
||||
{
|
||||
ValidateBatchLimits(request);
|
||||
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
return await database.ExecuteFreshAsync([vault], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
@@ -536,9 +534,21 @@ internal sealed class SyncService(
|
||||
|
||||
/// <summary>Attaches current row state to change-log entries.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
|
||||
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
|
||||
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>An entry an item has since moved past is left out.</b> The log keeps every revision, but the
|
||||
/// table keeps only the latest ciphertext — so hydrating an old entry paired that entry's revision number
|
||||
/// with the newest payload, and the payload's AAD binds the revision it was sealed at. A client opened it
|
||||
/// at the old number, failed, and parked its own unsent edit to that item as undecryptable. The later
|
||||
/// entry is in this page or a later one, since the item's <c>ChangeSequence</c> is that entry's sequence;
|
||||
/// it carries the same payload under the right number, so nothing is lost by sending it once. Leaving
|
||||
/// the old entries out also stops a fresh device downloading an item once per edit it ever had. The
|
||||
/// cursor is computed from the unfiltered page by the caller, so it still moves past them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
||||
Vault vault,
|
||||
@@ -571,10 +581,13 @@ internal sealed class SyncService(
|
||||
}
|
||||
}
|
||||
|
||||
return [.. changes.Select(change => Describe(
|
||||
change,
|
||||
kinds.GetValueOrDefault(change.EntityType),
|
||||
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
||||
return [.. changes
|
||||
.Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
|
||||
|| row.ChangeSequence <= change.Sequence)
|
||||
.Select(change => Describe(
|
||||
change,
|
||||
kinds.GetValueOrDefault(change.EntityType),
|
||||
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
||||
}
|
||||
|
||||
/// <summary>Turns one change-log entry and its row into what a client receives.</summary>
|
||||
|
||||
@@ -179,8 +179,8 @@ internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamS
|
||||
}
|
||||
|
||||
// Owner, not admin. An admin the owner promoted must not be able to archive the team out
|
||||
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this
|
||||
// re-checks it, unlike the transfer below, so this line is the whole of the guard.
|
||||
// from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
|
||||
// checks it again, because deleting a team's last vault archives it too.
|
||||
if (!access.IsOwner)
|
||||
{
|
||||
return Problems.Coded(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Serialises the writes that decide what a team owns, for the life of the transaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Archiving a team refuses while it owns a vault, and creating a vault refuses on an archived team — but each
|
||||
/// used to check outside any transaction the other could see, so the two could pass each other: the archive
|
||||
/// counted no vaults, a create landed a moment later, and the team was archived with a live vault in it that
|
||||
/// nobody could reach. Both now take this lock inside their transaction and check again under it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The same advisory lock the sync push takes per vault, keyed apart so a team and a vault never share one.
|
||||
/// Requires <c>Multiplexing=false</c> on the connection, as that one does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class TeamLock
|
||||
{
|
||||
internal static Task<int> AcquireAsync(DodoDbContext database, Guid teamId, CancellationToken cancellationToken) =>
|
||||
database.Database.ExecuteSqlAsync(
|
||||
$"SELECT pg_advisory_xact_lock(hashtextextended({"team:" + teamId.ToString()}, 0))",
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using DodoSSH.Api.Features.Events;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
@@ -244,28 +245,37 @@ internal sealed class TeamService(
|
||||
Team team,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actor);
|
||||
ArgumentNullException.ThrowIfNull(team);
|
||||
|
||||
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (vaultCount > 0)
|
||||
// Both callers check this first and answer the caller properly; this is what stops a third one that
|
||||
// forgets from letting an admin archive the team out from under its owner.
|
||||
if (!(await ResolveAsync(actor.Id, team.Id, cancellationToken).ConfigureAwait(false)).IsOwner)
|
||||
{
|
||||
throw new TeamNotEmptyException(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
|
||||
throw new InvalidOperationException("Only the owner of a team can archive it.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
var archived = await strategy.ExecuteAsync(async () =>
|
||||
var archived = await database.ExecuteFreshAsync([team], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var _ = transaction.ConfigureAwait(false);
|
||||
|
||||
// Counted under the lock a vault create takes, so one cannot land between this and the archive.
|
||||
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (vaultCount > 0)
|
||||
{
|
||||
throw new TeamNotEmptyException(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
|
||||
}
|
||||
|
||||
var memberships = await database.TeamMemberships
|
||||
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
@@ -335,9 +345,7 @@ internal sealed class TeamService(
|
||||
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
await database.ExecuteFreshAsync([incoming, outgoing], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
@@ -716,9 +724,7 @@ internal sealed class TeamService(
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
var revoked = await strategy.ExecuteAsync(async () =>
|
||||
var revoked = await database.ExecuteFreshAsync([membership], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
|
||||
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
|
||||
{
|
||||
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
|
||||
|
||||
if (orphaned is not null)
|
||||
// Archived only by its owner. Archiving revokes every membership, the owner's included, and an
|
||||
// admin the owner promoted must not be able to do that by deleting vaults one at a time when the
|
||||
// archive endpoint refuses them outright. An admin's delete stands; the empty team is left for
|
||||
// the owner, who can archive it or make a vault in it.
|
||||
if (orphaned is not null
|
||||
&& (await teams.ResolveAsync(user.Id, orphaned.Id, ct).ConfigureAwait(false)).IsOwner)
|
||||
{
|
||||
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Api.Features.Events;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
@@ -76,19 +77,7 @@ internal sealed class VaultGrantService(
|
||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
|
||||
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||
{
|
||||
// The pre-check covers the ordinary case; this is the race between two creates choosing
|
||||
// the same id, which must not surface as a 500 about a constraint.
|
||||
throw new TeamInvalidException(
|
||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
await WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
||||
|
||||
@@ -103,6 +92,55 @@ internal sealed class VaultGrantService(
|
||||
RekeyRequired: false);
|
||||
}
|
||||
|
||||
/// <summary>Writes the vault and its first grant, under the team's lock, in one transaction.</summary>
|
||||
private Task WriteVaultAsync(
|
||||
UserAccount user,
|
||||
Team team,
|
||||
CreateTeamVaultRequest request,
|
||||
string name,
|
||||
UserKey key,
|
||||
CancellationToken cancellationToken) =>
|
||||
database.ExecuteFreshAsync([team], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var _ = transaction.ConfigureAwait(false);
|
||||
|
||||
// Under the lock an archive takes, and the team re-read under it: see TeamLock.
|
||||
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
|
||||
await RequireTeamStillLiveAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||
{
|
||||
// The pre-check covers the ordinary case; this is the race between two creates choosing
|
||||
// the same id, which must not surface as a 500 about a constraint.
|
||||
throw new TeamInvalidException(
|
||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
/// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
|
||||
private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
|
||||
{
|
||||
var live = await database.Teams
|
||||
.AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!live)
|
||||
{
|
||||
throw new TeamInvalidException("This team has been archived, so it can no longer own a new vault.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
||||
private void AddVaultWithSelfGrant(
|
||||
UserAccount user,
|
||||
|
||||
@@ -41,17 +41,20 @@ builder.Services.AddScoped<VaultGrantService>();
|
||||
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
||||
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||
|
||||
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
|
||||
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
|
||||
// write paths that announce a change need only the two methods that announce one, and should not gain
|
||||
// a reference to connection management to get them.
|
||||
// A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
|
||||
// whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
|
||||
// that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
|
||||
// vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
|
||||
builder.Services.AddSingleton<VaultEventHub>();
|
||||
builder.Services.AddSingleton<IVaultEventPublisher>(
|
||||
provider => provider.GetRequiredService<VaultEventHub>());
|
||||
builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
|
||||
|
||||
// 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>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, ActiveAccountHandler>();
|
||||
|
||||
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
||||
|
||||
@@ -62,7 +62,14 @@ internal static class Auth
|
||||
|
||||
private static void AddDodoPolicies(AuthorizationOptions options)
|
||||
{
|
||||
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
|
||||
// Both policies also require the account to be active. A suspended or deprovisioned account still
|
||||
// holds a token the identity provider will vouch for until it expires — the status is this server's
|
||||
// own decision, so it is this server that has to refuse. See ActiveAccountHandler.
|
||||
options.AddPolicy(
|
||||
AuthenticatedPolicy,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new Authorization.ActiveAccountRequirement()));
|
||||
|
||||
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
|
||||
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
||||
@@ -72,7 +79,7 @@ internal static class Auth
|
||||
EnrolledPolicy,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new Authorization.EnrolledRequirement()));
|
||||
.AddRequirements(new Authorization.ActiveAccountRequirement(), new Authorization.EnrolledRequirement()));
|
||||
|
||||
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
||||
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a write transaction under the retrying execution strategy so that a retry starts from the database
|
||||
/// rather than from what the failed attempt left in the change tracker.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The strategy re-runs the delegate on the same context, and the context remembers the attempt that
|
||||
/// failed.</b> When the failure is the commit itself — a connection dropped after <c>SaveChanges</c> had
|
||||
/// already succeeded — every entity that attempt wrote is marked unchanged, holding values the database
|
||||
/// rolled back. The retry then assigns the same values again, change detection sees nothing to save, and
|
||||
/// the transaction commits without them. Enrolment lost <c>EnrolledAtUtc</c> that way, and a team hand-over
|
||||
/// its two role changes, each reporting success.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So a retry clears the tracker, and reloads each entity the delegate was handed from outside it — the ones
|
||||
/// it modifies or reads but did not load itself — so they are tracked again with what the database actually
|
||||
/// holds and the delegate's own assignments register as changes. The first attempt runs exactly as it did
|
||||
/// before: nothing is cleared or reloaded until something has failed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class FreshAttempts
|
||||
{
|
||||
/// <summary>Runs <paramref name="attempt"/> under the context's execution strategy, freshly each time.</summary>
|
||||
/// <param name="database">The request's context.</param>
|
||||
/// <param name="carried">Entities loaded before the transaction that the attempt reads or modifies.</param>
|
||||
/// <param name="attempt">The transaction, which begins and commits its own.</param>
|
||||
internal static Task<T> ExecuteFreshAsync<T>(
|
||||
this DodoDbContext database,
|
||||
IReadOnlyList<object> carried,
|
||||
Func<Task<T>> attempt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
ArgumentNullException.ThrowIfNull(carried);
|
||||
ArgumentNullException.ThrowIfNull(attempt);
|
||||
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
var attempts = 0;
|
||||
|
||||
return strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
if (attempts++ > 0)
|
||||
{
|
||||
database.ChangeTracker.Clear();
|
||||
|
||||
foreach (var entity in carried)
|
||||
{
|
||||
await database.Entry(entity).ReloadAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return await attempt().ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ExecuteFreshAsync{T}" />
|
||||
internal static Task ExecuteFreshAsync(
|
||||
this DodoDbContext database,
|
||||
IReadOnlyList<object> carried,
|
||||
Func<Task> attempt) =>
|
||||
database.ExecuteFreshAsync(
|
||||
carried,
|
||||
async () =>
|
||||
{
|
||||
await attempt().ConfigureAwait(false);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
@@ -29,14 +30,18 @@ internal static class Persistence
|
||||
?? throw new InvalidOperationException(
|
||||
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
||||
|
||||
services.AddDbContext<DodoDbContext>(options => options
|
||||
services.AddDbContext<DodoDbContext>((provider, options) => options
|
||||
.UseNpgsql(connectionString, npgsql => npgsql
|
||||
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
||||
// Retry on transient faults only. Note this is safe here because writes go
|
||||
// through explicitly-managed transactions rather than relying on the execution
|
||||
// strategy to replay ambient ones.
|
||||
// Retry on transient faults only. A write transaction replayed by the strategy runs on
|
||||
// the same context, which still holds what the failed attempt tracked — every such
|
||||
// transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
|
||||
// the database instead.
|
||||
.EnableRetryOnFailure(3))
|
||||
.UseSnakeCaseNamingConvention());
|
||||
.UseSnakeCaseNamingConvention()
|
||||
// None in production. The seam a test uses to make a commit fail the way a dropped
|
||||
// connection would, which is the only way to watch a retry happen.
|
||||
.AddInterceptors(provider.GetServices<IInterceptor>()));
|
||||
|
||||
services.AddHealthChecks()
|
||||
.AddCheck<DatabaseHealthCheck>(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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<RelayAccessWatch>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,13 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
var shell = new PhoneShell();
|
||||
|
||||
var viewModel = ComposeShell(
|
||||
paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell));
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
connections,
|
||||
ClipboardWriter(shell),
|
||||
ClipboardReader(shell));
|
||||
|
||||
shell.DataContext = viewModel;
|
||||
|
||||
@@ -126,7 +132,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
var keepAlive = new SessionKeepAlive(
|
||||
workspace,
|
||||
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
|
||||
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession);
|
||||
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.
|
||||
@@ -142,6 +149,11 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -176,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.
|
||||
@@ -186,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.
|
||||
@@ -236,6 +269,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
deviceName: PhoneEnvironment.DeviceName,
|
||||
|
||||
copyToClipboard: copyToClipboard,
|
||||
readClipboard: readClipboard,
|
||||
|
||||
updates: updates);
|
||||
|
||||
|
||||
@@ -90,6 +90,11 @@ public sealed class MainActivity : AvaloniaMainActivity
|
||||
PhoneEnvironment.CurrentActivity = this;
|
||||
|
||||
AndroidRedirectCallback.Abandon();
|
||||
|
||||
// And the keyboard may have gone away while this activity was not on screen, without Avalonia's
|
||||
// input pane raising anything to say so. PhoneShell re-reads the inset from this. See
|
||||
// PhoneEnvironment.Resumed, which carries the whole of why.
|
||||
PhoneEnvironment.RaiseResumed();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -100,6 +100,35 @@ internal static class PhoneEnvironment
|
||||
/// </remarks>
|
||||
public static global::Android.App.Activity? CurrentActivity { get; set; }
|
||||
|
||||
/// <summary>Raised each time the launcher activity comes back to the foreground.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Exists because the software keyboard can go away without Avalonia being told.</b>
|
||||
/// <c>IInputPane.StateChanged</c> is the only signal <c>PhoneShell</c> has for the keyboard opening and
|
||||
/// closing, and it is not raised when Android dismisses the keyboard on the way out of the app — the
|
||||
/// activity is being paused, and the pane is simply gone by the time anything could have reported it.
|
||||
/// So the inset that holds the interface clear of the keyboard was still applied on the way back in,
|
||||
/// and the whole phone drew an entire keyboard's height above the bottom of a screen with no keyboard
|
||||
/// on it. Reproduced every time: open the keyboard anywhere, press Home, return — 838 of 2340 pixels
|
||||
/// dead, and staying that way until the keyboard was opened and closed again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An event here rather than a call into the view, because the view is Avalonia's and this is Android's:
|
||||
/// <c>MainActivity</c> already owns <c>OnResume</c> and already writes <see cref="CurrentActivity"/>
|
||||
/// from it, and a static event is the same shape of hand-off one step further. Anything else that needs
|
||||
/// to re-read platform state on the way back in can subscribe without <c>MainActivity</c> learning what
|
||||
/// it is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Subscribers are responsible for unsubscribing — this is a static event and holds whatever it is
|
||||
/// given for the life of the process. <c>PhoneShell</c> attaches and detaches with its visual tree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static event EventHandler? Resumed;
|
||||
|
||||
/// <summary>Announces that the launcher activity is foreground again.</summary>
|
||||
internal static void RaiseResumed() => Resumed?.Invoke(null, EventArgs.Empty);
|
||||
|
||||
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
|
||||
public static Context Require() =>
|
||||
context ?? throw new InvalidOperationException(
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using global::Android.Views;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
|
||||
/// back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This replaces <c>TerminalFocus</c>, and it is the opposite move.</b> That class handed Android's own
|
||||
/// view focus <em>back</em> to the <c>WebView</c> after Avalonia's <c>DispatchTouchEvent</c> took it, because
|
||||
/// the input connection the keyboard was attached to belonged to the page. Under ADR 0015 it no longer does:
|
||||
/// <see cref="Views.TerminalInputSurface"/> is the input target, and it is an Avalonia control, so the focus
|
||||
/// the platform requests for Avalonia's view after every handled touch is now the focus we want. The fight
|
||||
/// ends by removing one of the contestants rather than by refereeing it faster.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Non-focusable, not non-interactive, and the distinction is the whole design.</b> Focusability governs
|
||||
/// where keyboard and D-pad input go; it has nothing to do with touch delivery. A <c>WebView</c> with
|
||||
/// <c>Focusable=false</c> still receives, scrolls and hit-tests every touch it is given — so the page keeps
|
||||
/// its <c>touch-action: pan-y</c> scrollback exactly as before — but it can no longer take an input
|
||||
/// connection, and Chromium's own <c>showSoftInput</c> for the focused DOM node cannot succeed from an
|
||||
/// unfocused view. That is precisely the split wanted: the page keeps the finger, Avalonia keeps the
|
||||
/// keyboard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why a tap has to be reported at all.</b> With the page unable to raise a keyboard, the ordinary way
|
||||
/// back after the system back gesture lowers one — touch the terminal — would otherwise do nothing. The
|
||||
/// listener is attached with <c>Handled = false</c> throughout, so it observes without consuming: the
|
||||
/// <c>WebView</c>'s own <c>OnTouchEvent</c> runs after it exactly as if nothing were listening.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A tap is distinguished from a scroll the way every Android view does it, by
|
||||
/// <c>ViewConfiguration.ScaledTouchSlop</c>. Without that test, dragging the scrollback would raise the
|
||||
/// keyboard over the very output being scrolled to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
|
||||
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
|
||||
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
|
||||
/// terminal. Every step is allowed to be absent — there is no activity while backgrounded, and no
|
||||
/// <c>WebView</c> until the terminal surface has been shown once.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class RendererFocus
|
||||
{
|
||||
/// <summary>The view already configured, so that showing the terminal twice does not listen twice.</summary>
|
||||
/// <remarks>
|
||||
/// Weak, because this is a static holding an Android view: a strong reference would keep the whole view
|
||||
/// hierarchy, and the activity behind it, alive for the life of the process.
|
||||
/// </remarks>
|
||||
private static WeakReference<global::Android.Webkit.WebView>? configured;
|
||||
|
||||
/// <summary>
|
||||
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
|
||||
/// taps it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
|
||||
/// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is
|
||||
/// free to tear its renderer down and build a new one, which is a case this head already handles
|
||||
/// elsewhere (see <c>docs/android-port.md</c>).
|
||||
/// </remarks>
|
||||
public static void Suppress(Action onTapped)
|
||||
{
|
||||
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (FindWebView(decor) is not { } webView)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (configured is not null && configured.TryGetTarget(out var already) && ReferenceEquals(already, webView))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
webView.Focusable = false;
|
||||
webView.FocusableInTouchMode = false;
|
||||
webView.ClearFocus();
|
||||
|
||||
WatchForTaps(webView, onTapped);
|
||||
|
||||
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A tap is a press and a release that did not travel further than the platform's own
|
||||
/// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious:
|
||||
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
|
||||
/// </remarks>
|
||||
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
|
||||
{
|
||||
// The fallback is the platform's own historical default, for a context that has somehow gone.
|
||||
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
|
||||
var downX = 0f;
|
||||
var downY = 0f;
|
||||
var moved = false;
|
||||
|
||||
webView.Touch += (_, e) =>
|
||||
{
|
||||
// Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
|
||||
e.Handled = false;
|
||||
|
||||
if (e.Event is not { } motion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (motion.Action)
|
||||
{
|
||||
case MotionEventActions.Down:
|
||||
downX = motion.GetX();
|
||||
downY = motion.GetY();
|
||||
moved = false;
|
||||
|
||||
break;
|
||||
|
||||
case MotionEventActions.Move:
|
||||
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
|
||||
|
||||
break;
|
||||
|
||||
case MotionEventActions.Up when !moved:
|
||||
onTapped();
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
|
||||
{
|
||||
for (var i = 0; i < parent.ChildCount; i++)
|
||||
{
|
||||
switch (parent.GetChildAt(i))
|
||||
{
|
||||
case global::Android.Webkit.WebView webView:
|
||||
return webView;
|
||||
|
||||
case ViewGroup child when FindWebView(child) is { } found:
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,11 @@ internal sealed class SessionForegroundService : Service
|
||||
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
||||
/// <param name="activeTransfers">Transfers still moving bytes.</param>
|
||||
/// <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
|
||||
@@ -152,7 +157,7 @@ internal sealed class SessionForegroundService : Service
|
||||
/// 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)
|
||||
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
|
||||
{
|
||||
var context = PhoneEnvironment.Require();
|
||||
|
||||
@@ -165,7 +170,7 @@ internal sealed class SessionForegroundService : Service
|
||||
|
||||
// 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.
|
||||
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession);
|
||||
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession, listeningForwards);
|
||||
|
||||
// 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
|
||||
@@ -255,15 +260,22 @@ internal sealed class SessionForegroundService : Service
|
||||
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
|
||||
}
|
||||
|
||||
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession)
|
||||
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
|
||||
{
|
||||
var parts = new List<string>(3);
|
||||
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");
|
||||
|
||||
@@ -24,12 +24,20 @@ namespace DodoSSH.Client.Android.Platform;
|
||||
/// 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">
|
||||
@@ -40,11 +48,20 @@ internal sealed class SessionKeepAlive : IDisposable
|
||||
/// 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>
|
||||
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers, Func<bool> holdsFileSession)
|
||||
/// <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 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
|
||||
@@ -56,14 +73,15 @@ internal sealed class SessionKeepAlive : IDisposable
|
||||
|
||||
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
||||
/// <remarks>
|
||||
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
|
||||
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
|
||||
/// 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(), holdsFileSession());
|
||||
SessionForegroundService.Reconcile(
|
||||
workspace.LiveSessionCount, activeTransfers(), holdsFileSession(), listeningForwards());
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
@@ -72,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, false);
|
||||
SessionForegroundService.Reconcile(0, 0, false, 0);
|
||||
}
|
||||
|
||||
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
using global::Android.Views;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Hands native focus back to the terminal's WebView after Avalonia chrome took it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> the
|
||||
/// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does
|
||||
/// not own. The accessory row's keys are already <c>Focusable=false</c> — see TerminalScreen — so Avalonia's
|
||||
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>:
|
||||
/// <c>AvaloniaView.DispatchTouchEvent</c> (decompiled from Avalonia.Android 12.1.1) ends every handled
|
||||
/// touch — DOWN and UP alike — with a <c>RequestFocus()</c> for Avalonia's own view. The WebView's input
|
||||
/// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text,
|
||||
/// and the inset churn that follows can leave it sitting on top of the very row that was tapped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.</b>
|
||||
/// The first version called <c>RequestFocus()</c> from the keys' own Click handlers, which fire
|
||||
/// <em>inside</em> the UP event's dispatch — and the platform's own request runs <em>after</em> dispatch
|
||||
/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
|
||||
/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
|
||||
/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
|
||||
/// about to be made stale by the very mechanism this exists to counter.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining
|
||||
/// native focus re-establishes the same input connection and the keyboard settles back to what it was.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, because the
|
||||
/// control does not expose its platform child and this application only ever has the one WebView — the
|
||||
/// walk's first match is necessarily the terminal. Every step is allowed to be absent, exactly as
|
||||
/// <see cref="SoftKeyboard.Hide"/>'s are: no activity while backgrounded, no WebView while the terminal
|
||||
/// surface has never been shown, and nothing to do in either case.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class TerminalFocus
|
||||
{
|
||||
public static void Return()
|
||||
{
|
||||
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (FindWebView(decor) is not { } webView)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
webView.Post(() =>
|
||||
{
|
||||
if (!webView.IsFocused)
|
||||
{
|
||||
webView.RequestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static View? FindWebView(ViewGroup parent)
|
||||
{
|
||||
for (var i = 0; i < parent.ChildCount; i++)
|
||||
{
|
||||
switch (parent.GetChildAt(i))
|
||||
{
|
||||
case global::Android.Webkit.WebView webView:
|
||||
return webView;
|
||||
|
||||
case ViewGroup child when FindWebView(child) is { } found:
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -167,10 +167,124 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
</Style>
|
||||
<!--
|
||||
◆ THE FILL BELOW IS NOT DECORATION. IT IS UNDOING ONE FLUENT PUTS THERE.
|
||||
|
||||
`accent` is not a name this theme invented — Fluent ships a `Button.accent` style of its own, and a
|
||||
button classed `icon accent` matches it. So these three buttons were being painted with Fluent's
|
||||
system-accent slab, #6275AC, a blue that appears nowhere in Palette.axaml and reads as a control from
|
||||
a different application sitting in this one's header. Measured off a screenshot rather than guessed:
|
||||
the fill was #6275AC where the accent is #5D42DE.
|
||||
|
||||
The rule above could not stop it, because it sets Foreground and FontSize and Fluent's fill is on the
|
||||
ContentPresenter — which is also why `Button.icon`'s own Background="Transparent" does not reach it.
|
||||
App.axaml has carried the desktop's answer to exactly this since v5b: override the ContentPresenter,
|
||||
not the Button. This head simply never had one, so the desktop looked right and the phone did not.
|
||||
|
||||
Transparent rather than the desktop's gradient, and the difference is the control rather than an
|
||||
inconsistency. The desktop's Button.accent is a filled primary slab — this head has one of those too,
|
||||
and it is Button.primary. `icon accent` is a 44-pixel glyph in a header row beside other glyphs; what
|
||||
"accent" asks for there is the accent *colour on the mark*, which is what the rule above gives it. A
|
||||
slab would make one header icon a button and its neighbours not.
|
||||
|
||||
Pressed is deliberately not restated: Button.icon:pressed below sets ChromeHover on every icon button
|
||||
and, coming later in this file, wins over this rule for the moment a finger is down. That is the
|
||||
feedback the plain icon buttons give and there is no reason this one should differ.
|
||||
-->
|
||||
<Style Selector="Button.icon.accent /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon.accent:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
============ one key of the terminal's accessory keypad ============
|
||||
|
||||
A class rather than eleven sets of attributes assigned in code, and the move out of the code-behind is
|
||||
most of what makes the latched state honest: Ctrl on and Ctrl off used to be two Background values
|
||||
written by hand in TerminalScreen.axaml.cs, which meant the row named two of this palette's brushes
|
||||
itself and any third state would have named a third. It is `latched` here, and the code toggles a class.
|
||||
|
||||
── the numbers, and why they are these ─────────────────────────────────────────────────────────────────
|
||||
40 tall, where the row this replaced was 30. Android asks for 48 on a touch target and every terminal on
|
||||
the platform lands near it; 30 was chosen to save a strip of the terminal above and took the keypad past
|
||||
the point of working, which is the wrong end of that trade. 40 with a 2-pixel margin gives 44 of pitch,
|
||||
which is the same figure TextBox.field uses for a box a thumb has to hit.
|
||||
|
||||
HorizontalAlignment Stretch, because these live in UniformGrid cells and the cell is the share of the
|
||||
width the key is entitled to. Left to Center a key would shrink to its label and "|" would be a sliver
|
||||
between two wide neighbours — which is the layout the flexed row exists to stop.
|
||||
|
||||
── pressed has to be visible, and it was not ───────────────────────────────────────────────────────────
|
||||
These keys are Focusable="False" so that using one does not take the keyboard away from the terminal
|
||||
(see TerminalScreen.axaml.cs), and a button that never takes focus also never shows the focus adorner
|
||||
Fluent would otherwise give it. That left a press with no feedback at all on a control whose entire job
|
||||
is to be pressed while somebody is looking at the line of output above it. The pressed fill below is the
|
||||
only thing that says a key was hit — and on a keypad over a remote shell, "did that send?" is a question
|
||||
worth answering in the interface rather than in the scrollback.
|
||||
-->
|
||||
<Style Selector="Button.hotkey">
|
||||
<Setter Property="Height" Value="40" />
|
||||
<Setter Property="MinHeight" Value="40" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="Margin" Value="2,0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
<!--
|
||||
Latched Ctrl. The same fill a press gives, held rather than flashed — a modifier that is on and does not
|
||||
look on is how somebody sends ^L to a database prompt believing they typed an l.
|
||||
-->
|
||||
<Style Selector="Button.hotkey.latched">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey.latched /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:disabled">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The floating action button the design has asked for since v2, and which this theme refused twice — on
|
||||
the grounds that it would open an editor this head had not got, and that an unused style is a claim the
|
||||
@@ -398,6 +512,58 @@
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ The two port boxes, which are NumericUpDowns and were the only unthemed controls on this head.
|
||||
|
||||
They sit in a two-column Grid with a plain TextBox.field beside them — port next to username in the host
|
||||
editor, and port next to default-user in the group editor — so the mismatch was not subtle: a bright
|
||||
Fluent border and a near-black fill against this theme's #101019 and hairline, at the one place on the
|
||||
screen where the two are a hand's width apart and directly comparable.
|
||||
|
||||
Not `Classes="field"` on the two call sites, because that class is a TextBox class and this is not a
|
||||
TextBox: NumericUpDown is a ButtonSpinner wrapping one, so half the chrome being corrected belongs to
|
||||
the spinner and would not take a TextBox setter. Typing the control instead also means a third port box
|
||||
added later is right without anybody remembering, which is the property the desktop's own bare
|
||||
`Selector="NumericUpDown"` in App.axaml has had since it was written.
|
||||
|
||||
Three selectors rather than one for the same reason Button.icon.accent needs two: Fluent puts the fill
|
||||
and the border on template parts, and a value set on the control alone is a TemplateBinding the theme's
|
||||
own wins over. The numbers are TextBox.field's, exactly — 44 tall, 10 radius, Field on BorderMid — so
|
||||
the pair reads as one row of two boxes rather than as a box and a visitor.
|
||||
-->
|
||||
<Style Selector="NumericUpDown">
|
||||
<Setter Property="Height" Value="44" />
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="NumericUpDown /template/ ButtonSpinner">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
</Style>
|
||||
<!--
|
||||
The inner box gives its chrome up entirely: the spinner above is drawing the border and the fill, and a
|
||||
TextBox that kept its own would put a second rounded rectangle a pixel inside the first.
|
||||
-->
|
||||
<Style Selector="NumericUpDown /template/ TextBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="14,0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Every box on this head that takes a secret, and it is a class rather than two attributes repeated five
|
||||
times because the two attributes are not interchangeable and both are needed.
|
||||
@@ -473,10 +639,29 @@
|
||||
<Setter Property="FontSize" Value="9.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<!--
|
||||
◆ Wraps, like .body below it, and the omission was doing real damage rather than none.
|
||||
|
||||
A TextBlock that neither wraps nor trims does not shrink to fit and does not report that it did not:
|
||||
it lays out at its full single-line width and the parent clips whatever runs past the edge. So a
|
||||
.detail long enough to overflow ended mid-word against the screen, with no ellipsis to say anything
|
||||
was missing — "A machine to connect to, filed under a group if you" on the add sheet, which reads as
|
||||
a sentence somebody forgot to finish rather than as text that was cut.
|
||||
|
||||
It is a style rather than an attribute on the ones that overflow today, because which of the fifty-odd
|
||||
.detail blocks overflows is a function of the device's width and the user's font scale, not of the
|
||||
string: every one of them is a candidate at 320dp that is fine at 411dp, and fixing the handful that
|
||||
happen to be visibly wrong on a Pixel 5 would leave the rest to be found one narrow phone at a time.
|
||||
|
||||
The nine that genuinely want one line — list rows and log lines, where a wrapped detail would push the
|
||||
row's own height around — now say TextWrapping="NoWrap" beside their TextTrimming, which is the pairing
|
||||
that was always meant and was previously being had by accident.
|
||||
-->
|
||||
<Style Selector="TextBlock.detail">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.body">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
|
||||
@@ -551,7 +551,7 @@
|
||||
Foreground="{StaticResource Accent}"
|
||||
Background="{StaticResource Raised}" />
|
||||
<TextBlock Classes="detail" Text="{Binding Progress}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -49,12 +49,29 @@
|
||||
|
||||
<TextBlock Classes="title" Text="UNKNOWN HOST KEY" FontSize="13" />
|
||||
|
||||
<!--
|
||||
◆ The port and the full stop are one Run, and that is the whole of the fix.
|
||||
|
||||
Avalonia puts a space between adjacent Runs, which is what makes "First contact with", the
|
||||
host and the separator read as a sentence without any of them carrying padding. The trailing
|
||||
Run began with a full stop, so it got that space too and the sheet said
|
||||
|
||||
First contact with 10.0.2.2 · 2222 . Nothing in this keychain…
|
||||
|
||||
— a gap before the stop, on the one dialogue in this application whose entire job is to be
|
||||
read carefully before somebody answers it. StringFormat welds the stop to the number inside a
|
||||
single Run, where no inter-Run space can get in, and the sentence that follows starts a Run of
|
||||
its own so it still gets the space it wants.
|
||||
|
||||
The `{}` prefix is required: a StringFormat beginning with a brace would otherwise be parsed
|
||||
as a markup extension.
|
||||
-->
|
||||
<TextBlock Classes="body" Margin="0,6,0,0">
|
||||
<Run Text="First contact with" />
|
||||
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
||||
<Run Text="·" />
|
||||
<Run Text="{Binding PendingHostKey.Port}" />
|
||||
<Run Text=". Nothing in this keychain has approved this key." />
|
||||
<Run Text="{Binding PendingHostKey.Port, StringFormat='{}{0}.'}" />
|
||||
<Run Text="Nothing in this keychain has approved this key." />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
|
||||
</StackPanel>
|
||||
@@ -1002,7 +1002,8 @@
|
||||
FormatString="0" ShowButtonSpinner="False" MinHeight="44"
|
||||
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
||||
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}"
|
||||
IsEnabled="{Binding EditorAsksForLogin}" />
|
||||
</Grid>
|
||||
|
||||
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
|
||||
@@ -1016,6 +1017,7 @@
|
||||
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
|
||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||
IsEnabled="{Binding EditorAsksForLogin}"
|
||||
HorizontalAlignment="Stretch" MinHeight="44">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
@@ -1028,6 +1030,9 @@
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<!-- Why the login is greyed out, as on the desktop. See VaultViewModel.EditorAsksForLogin. -->
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !EditorAsksForLogin}"
|
||||
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
|
||||
|
||||
<!--
|
||||
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
|
||||
@@ -1199,19 +1204,53 @@
|
||||
port into plaintext columns the server can read, which is the single deliberate concession in
|
||||
the design — see ADR 0004 — so it says so rather than being a switch labelled "relay".
|
||||
|
||||
◆ AND IT SAYS, FIRST, THAT IT BUYS NOTHING TODAY. The relay's server half is built and this
|
||||
client has no path to it — the connect path dials the address directly whether this is ticked or
|
||||
not — so the box as it stood spent that concession and delivered nothing for it. Left tickable
|
||||
rather than disabled, so a host already carrying the flag can lose it. See
|
||||
It also says the two things a user would otherwise find out by failing: the server, not this
|
||||
phone, has to be able to reach the address, and a relayed host does not fall back to dialling
|
||||
directly, so it connects only while the server is reachable. See
|
||||
docs/reaching-a-host-you-cannot-dial.md.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5"
|
||||
Text="Reach this host through the server relay (not wired up yet)"
|
||||
Text="Reach this host through the server relay"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<TextBlock Classes="body"
|
||||
Text="Not built yet: this app always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it works, the relay will dial on your behalf — which is why the address and port have to be stored in the clear. Everything else about the host stays encrypted either way, and a relayed host needs a port of its own rather than its group's." />
|
||||
Text="The server dials this host on your behalf, for a machine this phone cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in the clear; everything else about the host stays encrypted. A relayed host needs a port of its own rather than its group's, and connects only while you are online." />
|
||||
|
||||
<!--
|
||||
The relay's alternative, and the desktop drawer's Spelunk card in this head's controls: ticking
|
||||
one unticks the other. The key is a keychain credential chosen here, never typed here. See
|
||||
VaultViewModel.Spelunk.cs.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5"
|
||||
Text="Reach this host through Spelunk"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<StackPanel Spacing="8" IsVisible="{Binding EditorThroughSpelunk}">
|
||||
<TextBox Classes="field" Text="{Binding EditorSpelunkApi}"
|
||||
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||
<TextBox Classes="field" Text="{Binding EditorSpelunkServerId}"
|
||||
PlaceholderText="Spelunk server id" />
|
||||
<TextBlock Classes="detail" Text="Spelunk API key" />
|
||||
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
|
||||
SelectedItem="{Binding EditorSelectedSpelunkKey}"
|
||||
HorizontalAlignment="Stretch" MinHeight="44">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<!-- The desktop card's sign-in box. See SpelunkRoute.SignInThroughSpelunk. -->
|
||||
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5"
|
||||
Text="Sign in through Spelunk (no SSH key needed)"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="body"
|
||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Choose a Spelunk API key (Admin role) saved from the desktop's Settings → Security, or a credential whose password is the key; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. Connects only while Spelunk and its agent are reachable." />
|
||||
|
||||
<!--
|
||||
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- What is known *about* an item, never the secret. -->
|
||||
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
|
||||
@@ -106,11 +106,11 @@
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
||||
Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
|
||||
Text="{Binding Address}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!--
|
||||
TextDim rather than the `detail` class's own faint step. This block is drawn on
|
||||
@@ -124,7 +124,7 @@
|
||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
Text="·" />
|
||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding DeviceName}" TextTrimming="CharacterEllipsis" />
|
||||
Text="{Binding DeviceName}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- A refused host key is the one outcome worth a colour of its own. -->
|
||||
@@ -158,7 +158,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
||||
Text="{Binding ItemLabel}" TextTrimming="CharacterEllipsis" />
|
||||
Text="{Binding ItemLabel}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!-- The names of the fields that changed, never their values. -->
|
||||
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasChangedFields}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -391,11 +391,15 @@ internal sealed partial class PhoneShell : UserControl
|
||||
ApplyKeyboardInset(keyboard);
|
||||
}
|
||||
}
|
||||
|
||||
PhoneEnvironment.Resumed += OnActivityResumed;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
PhoneEnvironment.Resumed -= OnActivityResumed;
|
||||
|
||||
if (TopLevel.GetTopLevel(this) is { } top)
|
||||
{
|
||||
top.BackRequested -= OnBackRequested;
|
||||
@@ -410,6 +414,43 @@ internal sealed partial class PhoneShell : UserControl
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the keyboard inset on the way back into the application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The margin can be stale here and there is no other moment it gets corrected.</b> Android takes
|
||||
/// the software keyboard away when this activity stops, and Avalonia's input pane raises no
|
||||
/// <c>StateChanged</c> for it — the pane is gone with the window rather than closed in front of one. So
|
||||
/// <see cref="ApplyKeyboardInset(double)"/> last ran for a keyboard that was up, and the interface comes
|
||||
/// back holding a whole keyboard's worth of empty space it no longer needs. It is not subtle and it does
|
||||
/// not recover: a third of the display, on every screen, until something opens and closes the keyboard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asking the pane rather than assuming zero, because resuming does not always mean the keyboard went.
|
||||
/// A permission dialogue, the biometric sheet and the sign-in browser all pause this activity and can
|
||||
/// all hand it back with the keyboard exactly as it was — and a handler that flattened the inset on the
|
||||
/// way in would then strand the focused box under a keyboard that is genuinely there. The pane knows
|
||||
/// which of the two happened; this only has to ask it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Posted rather than run inline. <c>OnResume</c> is early — the window has not necessarily been laid
|
||||
/// out again and the pane's own state is settled by the insets that arrive with the next frame, so
|
||||
/// reading it on this turn can return the state that is on the way out. Background priority puts the
|
||||
/// read after that.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnActivityResumed(object? sender, EventArgs e) =>
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (keyboard is not null)
|
||||
{
|
||||
ApplyKeyboardInset(keyboard);
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
|
||||
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
||||
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
|
||||
|
||||
@@ -538,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>
|
||||
@@ -557,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;
|
||||
}
|
||||
@@ -606,6 +646,31 @@ 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>
|
||||
|
||||
@@ -163,6 +163,8 @@
|
||||
Text="Text size is on the terminal itself, where the text is — the phone puts A− and A+ in the shell's own bar rather than a step away from what they resize." />
|
||||
<TextBlock Classes="body"
|
||||
Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." />
|
||||
<TextBlock Classes="body"
|
||||
Text="Importing a Hetzner Cloud project would work here — it is a token and a network request, not a file — and the wizard behind it is already shared. What is missing is a phone layout for it: the desktop draws a five-column table, and the screen it would become is not the desktop's with narrower columns. Until then it is a thing to do on a desktop; the hosts it makes sync here like any other." />
|
||||
<TextBlock Classes="body"
|
||||
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
|
||||
</StackPanel>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>
|
||||
/// One keystroke's worth of bytes, on its way from <see cref="TerminalInputSurface"/> to the shell.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bytes rather than text, because half of what a terminal receives has no text form: a cursor key is an
|
||||
/// escape sequence and Ctrl-C is a single byte below the printable range. Encoding at the surface — where
|
||||
/// the key is still known — rather than downstream is what keeps the send path from having to guess which
|
||||
/// kind it was handed.
|
||||
/// </remarks>
|
||||
/// <param name="bytes">The encoded keystroke.</param>
|
||||
internal sealed class TerminalInputEventArgs(byte[] bytes) : EventArgs
|
||||
{
|
||||
public byte[] Bytes { get; } = bytes;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using System.Text;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.TextInput;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The terminal's keyboard, as an Avalonia control that draws nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This exists so that the software keyboard is configured by us rather than by Chromium</b>, and that
|
||||
/// single sentence is the whole of ADR 0015. A phone keyboard over a terminal has to be told to stop
|
||||
/// autocorrecting, stop predicting, stop capitalising and stop offering a suggestion strip, and on Android
|
||||
/// the only setting that actually achieves it is an <c>EditorInfo.inputType</c> carrying
|
||||
/// <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD</c>. A page inside a <c>WebView</c> cannot reach that field —
|
||||
/// Chromium derives the whole <c>EditorInfo</c> from the focused DOM node, and xterm.js already sets every
|
||||
/// attribute the web platform offers (<c>autocorrect</c>, <c>autocapitalize</c>, <c>spellcheck</c>) to no
|
||||
/// effect, because they are advisory and Gboard ignores them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Avalonia can reach it, on the version this repository already pins.</b>
|
||||
/// <c>AndroidInputMethod.OnCreateInputConnection</c> in Avalonia.Android 12.1.1 reads:
|
||||
/// <code>
|
||||
/// if ((int)outAttrs.InputType == 1 && 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();
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,34 @@
|
||||
<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>
|
||||
@@ -139,7 +167,7 @@
|
||||
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
|
||||
@@ -244,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>
|
||||
@@ -341,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}" />
|
||||
@@ -471,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 ============ -->
|
||||
@@ -482,13 +560,50 @@
|
||||
-->
|
||||
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
||||
<!--
|
||||
33 tall rather than 50, a third off it like the bar at the top, with the keys inside coming down from
|
||||
38 to 30 for the reason the pills did: a row that shrank around its own contents would clip them.
|
||||
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
|
||||
|
||||
The five pixels above it are a gap and not a border. The renderer is a native child view, so nothing
|
||||
Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its own —
|
||||
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
|
||||
that has gone wrong rather than as two that are different things.
|
||||
What this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
|
||||
and it was wrong in two ways that compounded each other.
|
||||
|
||||
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
|
||||
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
|
||||
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
|
||||
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
|
||||
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
|
||||
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
|
||||
to be the first thing anybody noticed about this row.
|
||||
|
||||
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
|
||||
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
|
||||
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
|
||||
which is a trade worth making right up until the control stops working, and 30 is past that.
|
||||
|
||||
── what the other clients do, because all of them agree ─────────────────────────────────────────────
|
||||
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
|
||||
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
|
||||
second row — Termux ships two by default. That is not convention for its own sake: a key whose
|
||||
position is fixed can be hit without looking, which is the entire point of a modifier row you use
|
||||
while reading the output above it. A key that might have scrolled has to be found first.
|
||||
|
||||
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
|
||||
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
|
||||
ones that benefit, because the arrows were the keys living past the right edge.
|
||||
|
||||
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
|
||||
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
|
||||
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
|
||||
Ctrl is not the better surface.
|
||||
|
||||
── the split ────────────────────────────────────────────────────────────────────────────────────────
|
||||
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
|
||||
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
|
||||
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
|
||||
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
|
||||
|
||||
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
|
||||
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of
|
||||
its own — and a terminal whose last line of output is flush against a row of grey keys reads as one
|
||||
surface that has gone wrong rather than as two that are different things.
|
||||
-->
|
||||
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||
<!--
|
||||
@@ -539,62 +654,70 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
|
||||
VerticalAlignment="Center" />
|
||||
</ScrollViewer>
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
|
||||
<!--
|
||||
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
|
||||
beside them.
|
||||
|
||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the state
|
||||
in which hunting for the control that fixes it is worst. That argument is answered rather than
|
||||
abandoned: outside the ScrollViewer they cannot scroll away, and the row they had costs 36 pixels
|
||||
on a surface this change exists to give back.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in this
|
||||
screen.
|
||||
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
|
||||
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
||||
TerminalScreen.axaml.cs. Every button on this row sits over a terminal somebody is typing into,
|
||||
and an ordinary one takes Avalonia's focus off the NativeWebView on tap, which makes the package
|
||||
resign the page's focus. Making the text smaller must not cost the keyboard.
|
||||
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
|
||||
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
|
||||
between them instead of taking what their labels happen to need — "ctrl" is four characters and
|
||||
"|" is one, and on a row of keys those two want the same target, not proportional ones.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
|
||||
VerticalAlignment="Center" />
|
||||
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
|
||||
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" Focusable="False"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
<!--
|
||||
The cursor keys, and the two text-size buttons riding at the end.
|
||||
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" Focusable="False"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
|
||||
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
|
||||
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
|
||||
them every time the row's contents changed.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
||||
|
||||
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
|
||||
|
||||
<!--
|
||||
The text-size buttons.
|
||||
|
||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the
|
||||
state in which hunting for the control that fixes it is worst. Nothing on this keypad scrolls
|
||||
any more, so that argument is now satisfied by construction rather than by placement.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in
|
||||
this screen.
|
||||
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
|
||||
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
||||
TerminalScreen.axaml.cs. Every button on this keypad sits over a terminal somebody is typing
|
||||
into, and an ordinary one takes Avalonia's focus off the input surface on tap. Making the text
|
||||
smaller must not cost the keyboard.
|
||||
-->
|
||||
<!-- No Spacing: Button.hotkey carries a 2-pixel side margin, so the gaps here match the
|
||||
UniformGrid cells' without being counted twice. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="8,0,0,0">
|
||||
<Border Width="1" Height="20" Background="{StaticResource Border}" Margin="0,0,4,0"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
@@ -674,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,18 +263,26 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
|
||||
private void BuildAccessoryRow()
|
||||
{
|
||||
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
||||
// ── nothing to hand back any more ──────────────────────────────────────────────────────────────
|
||||
// This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
|
||||
// the platform requests Android's own focus for Avalonia's view after dispatching each handled
|
||||
// touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
|
||||
// TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
|
||||
// the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
|
||||
// stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
|
||||
|
||||
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
|
||||
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
|
||||
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
|
||||
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
|
||||
// length of the press; and the Return is posted past the current dispatch, so its ordering against
|
||||
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
|
||||
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
|
||||
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
|
||||
}
|
||||
|
||||
foreach (var (label, bytes, latches) in Keys)
|
||||
/// <summary>Puts one table's worth of keys into one row of the keypad.</summary>
|
||||
/// <remarks>
|
||||
/// The rows differ in what they hold and in nothing else — same control, same class, same wiring — so
|
||||
/// they are filled by one method rather than by two that would drift.
|
||||
/// </remarks>
|
||||
private void Fill(UniformGrid row, (string Label, byte[] Bytes, bool Latches)[] keys)
|
||||
{
|
||||
foreach (var (label, bytes, latches) in keys)
|
||||
{
|
||||
var key = CreateKey(label);
|
||||
|
||||
@@ -171,49 +305,31 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
private static Button CreateKey(string label) =>
|
||||
new()
|
||||
{
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
Content = label,
|
||||
|
||||
// 44 wide, and that is the number that matters: the design draws these flexed across the
|
||||
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
|
||||
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
|
||||
// costs nothing a width does: the keys are a single row with the terminal above and the
|
||||
// system's gesture bar below, so there is no neighbour a short press can land on instead.
|
||||
MinWidth = 44,
|
||||
Height = 30,
|
||||
Padding = new Thickness(10, 0),
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Background = Palette("Panel"),
|
||||
BorderBrush = Palette("BorderMid"),
|
||||
BorderThickness = new Thickness(1),
|
||||
Foreground = Palette("TextDim"),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
// ◆ Every dimension and brush this used to set by hand is now Button.hotkey in Phone.axaml,
|
||||
// and the move is what lets the row have a pressed state at all: a style can say :pressed and
|
||||
// an assignment cannot. It also takes this file out of the business of naming the palette's
|
||||
// brushes, which is how a keypad ends up with a grey nothing else uses.
|
||||
//
|
||||
// Content is the string rather than a TextBlock built here for the same reason — the class
|
||||
// carries the font and the alignment, so a wrapper element would only be somewhere for them to
|
||||
// disagree.
|
||||
Classes = { "hotkey" },
|
||||
|
||||
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
||||
//
|
||||
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
||||
// ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView
|
||||
// — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing
|
||||
// Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next
|
||||
// thing typed on a hardware keyboard went nowhere, and the row went on working because its
|
||||
// buttons are pressed rather than typed into, which is what makes it look like the terminal
|
||||
// had died instead.
|
||||
// ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
|
||||
// terminal's input target is — and a modifier row that dismisses the keyboard every time it
|
||||
// is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
|
||||
// the focused element never changes: TerminalInputSurface is still it, so the input
|
||||
// connection is never torn down and nothing has to be handed back.
|
||||
//
|
||||
// Focusable=false is what a toolbar button is, and it means the focused element never
|
||||
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
||||
//
|
||||
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
|
||||
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
|
||||
// decides about its element — taking the keyboard's input connection off the terminal and
|
||||
// swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
|
||||
// that half back; see TerminalFocus for the whole story, including why the handing back has
|
||||
// to be posted rather than done inline.
|
||||
// This used to be only half a fix, because the target was the NativeWebView and Android keeps
|
||||
// a focus of its own that the platform hands to Avalonia's view after every touch it
|
||||
// dispatches. That half is gone rather than countered: under ADR 0015 the input target is an
|
||||
// Avalonia control, so the focus Android moves is already where the keyboard should be.
|
||||
Focusable = false,
|
||||
};
|
||||
|
||||
@@ -228,18 +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 & 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,128 @@ internal sealed partial class DodoSshApp : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Compose(desktop);
|
||||
// Recorded, not handled: see CrashLog for why the process still goes.
|
||||
Avalonia.Threading.Dispatcher.UIThread.UnhandledException +=
|
||||
(_, e) => CrashLog.RecordFromTheUiThread(e.Exception);
|
||||
|
||||
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 +203,17 @@ internal sealed partial class DodoSshApp : Application
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>Reads this machine's clipboard.</summary>
|
||||
/// <remarks>
|
||||
/// The other direction, looked up per call for the same reason and with the same fall-through. One
|
||||
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
|
||||
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
|
||||
/// </remarks>
|
||||
private static Func<Task<string?>> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) =>
|
||||
async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard }
|
||||
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
|
||||
/// <remarks>
|
||||
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
|
||||
@@ -126,12 +268,55 @@ internal sealed partial class DodoSshApp : Application
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
||||
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
||||
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
||||
// 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);
|
||||
WireShutdown(desktop, viewModel, workspace, caches, others);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the window somebody has just asked for by launching the application again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Un-minimised first, because activating a minimised window leaves it minimised and the launch would
|
||||
/// look ignored. <c>Activate</c> is a request rather than a guarantee — Windows refuses the foreground to
|
||||
/// a process that did not receive the input, and what happens then is a flashing taskbar button, which is
|
||||
/// still an answer. That is the platform's own rule about focus stealing and not a thing to work around.
|
||||
/// </remarks>
|
||||
private static void BringToFront(Window? window)
|
||||
{
|
||||
if (window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.WindowState is WindowState.Minimized)
|
||||
{
|
||||
window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
|
||||
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
|
||||
@@ -169,40 +354,11 @@ internal sealed partial class DodoSshApp : Application
|
||||
.ConfigureAwait(false),
|
||||
|
||||
copyToClipboard: ClipboardWriter(desktop),
|
||||
readClipboard: ClipboardReader(desktop),
|
||||
updates: updates,
|
||||
openInEditor: OpenInDefaultEditorAsync,
|
||||
openInEditor: LocalEditor.OpenAsync,
|
||||
watchFile: WatchFile);
|
||||
|
||||
/// <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 });
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches one file for saves, until the handle is disposed.
|
||||
/// </summary>
|
||||
@@ -259,7 +415,8 @@ internal sealed partial class DodoSshApp : Application
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
MainWindowViewModel viewModel,
|
||||
TerminalWorkspace workspace,
|
||||
ClientCacheFactory caches)
|
||||
ClientCacheFactory caches,
|
||||
IAsyncDisposable others)
|
||||
{
|
||||
var shuttingDown = false;
|
||||
|
||||
@@ -278,6 +435,11 @@ internal sealed partial class DodoSshApp : Application
|
||||
await viewModel.DisposeAsync().ConfigureAwait(true);
|
||||
await workspace.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
// Awaited rather than dropped, so the pipe name is free before this process is. It matters for
|
||||
// one caller and matters a lot there: an update relaunches this application, and a successor
|
||||
// that found the name still taken would be a second instance refusing itself.
|
||||
await others.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
caches.Dispose();
|
||||
|
||||
desktop.Shutdown();
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Writes down why the application died, in the profile, before it does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// An exception nothing caught used to close the window and leave nothing behind — no file, no event, no
|
||||
/// sentence — so the only report anybody could make was "it closed". This does not keep the process alive:
|
||||
/// an exception that reached the top is one no code was written to recover from, and carrying on in whatever
|
||||
/// state it left would be a guess. What it adds is the record.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Bounded, and appended to rather than rewritten, so a run of crashes keeps its history without the file
|
||||
/// growing for ever. Every write is best-effort: a profile that cannot be written to is a reason to lose the
|
||||
/// log, never a reason to fail harder.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class CrashLog
|
||||
{
|
||||
/// <summary>Past this, the next entry starts a fresh file.</summary>
|
||||
private const long MaxBytes = 1024 * 1024;
|
||||
|
||||
private static readonly Lock Gate = new();
|
||||
|
||||
private static string? file;
|
||||
|
||||
/// <summary>Starts recording, for the process's two last-chance hooks.</summary>
|
||||
/// <remarks>
|
||||
/// The UI thread's own hook is added by the application once Avalonia is running — see
|
||||
/// <see cref="RecordFromTheUiThread"/> — because the dispatcher must not be touched before the platform
|
||||
/// has set it up.
|
||||
/// </remarks>
|
||||
internal static void Install(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
file = paths.CrashLogFile;
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) => Write("Unhandled", e.ExceptionObject as Exception);
|
||||
|
||||
// A fire-and-forget task that faulted. It does not end the process, which is exactly why it needs
|
||||
// writing down: the work it was doing simply never happened, and nothing else would ever say so.
|
||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
Write("Unobserved task", e.Exception);
|
||||
e.SetObserved();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The UI thread's hook: records the exception and lets it take the process as before.</summary>
|
||||
internal static void RecordFromTheUiThread(Exception exception) => Write("UI thread", exception);
|
||||
|
||||
/// <summary>Appends one entry; never throws.</summary>
|
||||
internal static void Write(string where, Exception? exception)
|
||||
{
|
||||
if (file is not { } path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = $"{TimeProvider.System.GetUtcNow():O} {where}{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}";
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
if (File.Exists(path) && new FileInfo(path).Length > MaxBytes)
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
File.AppendAllText(path, entry);
|
||||
}
|
||||
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Best-effort, by design: see the type's remarks.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// What a platform key store calls the hardware key behind one profile's device unlock.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>One key per profile, because the key outlives the file and is shared by name.</b> The CNG key and
|
||||
/// the Secure Enclave keychain item are found by name in a store that belongs to the user, not to the
|
||||
/// profile directory. While every profile used the same name, the release build and the nightly — which
|
||||
/// <see cref="ClientPaths.ForChannel"/> keeps apart precisely so neither can damage the other — shared one
|
||||
/// key: signing out of either deleted it, and the other's device unlock silently stopped working.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The default profile keeps the name it has always had</b>, so an existing install's sealed key still
|
||||
/// opens. Every other profile gets that name with a digest of its directory on the end — a digest rather
|
||||
/// than the directory's own name, because a portable copy can live in any folder and two such folders can
|
||||
/// end in the same name.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class DeviceKeyNames
|
||||
{
|
||||
/// <summary>The name of the key behind <paramref name="paths"/>' device unlock.</summary>
|
||||
/// <param name="paths">The profile.</param>
|
||||
/// <param name="legacy">The name every profile used before names were per profile.</param>
|
||||
internal static string For(ClientPaths paths, string legacy)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
var directory = Normalise(paths.DataDirectory);
|
||||
|
||||
if (string.Equals(directory, Normalise(ClientPaths.Default.DataDirectory), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return legacy;
|
||||
}
|
||||
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(directory.ToUpperInvariant()));
|
||||
|
||||
return $"{legacy}.{Convert.ToHexStringLower(digest.AsSpan(0, 8))}";
|
||||
}
|
||||
|
||||
private static string Normalise(string directory) =>
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a local copy of a remote file for editing, without running it.
|
||||
/// </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. The shell decides <em>when</em>; see
|
||||
/// <c>TransfersViewModel.EditRemoteAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>"Open" is not "edit", and for a file somebody else named the difference is code execution.</b> The
|
||||
/// file comes from a host, and whoever can write to that host chooses its name. Handing <c>deploy.bat</c>,
|
||||
/// <c>notes.hta</c> or <c>run.command</c> to the desktop's default action runs it, on this machine, as this
|
||||
/// user — so a file whose default action is to run is opened with an editor instead. On Windows that is the
|
||||
/// type's own <c>edit</c> verb where it has one (Notepad, for <c>.bat</c>, <c>.cmd</c>, <c>.js</c>,
|
||||
/// <c>.vbs</c>, <c>.ps1</c> and <c>.reg</c>) and Notepad where it has not; on macOS it is <c>open -t</c>,
|
||||
/// which is the default text editor whatever the type. Everything else still goes to its association, which
|
||||
/// is the point of the feature — an <c>nginx.conf</c> should open in whatever this person edits configs with.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Linux is the exception: there is no portable "the text editor", so a <c>.desktop</c> entry — the one type
|
||||
/// <c>xdg-open</c> may launch rather than open — is refused with a sentence instead. A shell script is opened
|
||||
/// by every mainstream desktop's file associations in an editor, not run.
|
||||
/// </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>
|
||||
internal static class LocalEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions Windows runs rather than opens, beyond whatever <c>PATHEXT</c> lists on this machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>PATHEXT</c> covers the console's notion of executable (<c>.exe</c>, <c>.bat</c>, <c>.js</c>,
|
||||
/// <c>.vbs</c>…) and is read as well, because an interpreter's installer may add its own. These are the
|
||||
/// ones Explorer acts on that are not in it: shortcuts, installers, HTML applications, control panel
|
||||
/// items, and the script hosts that installers do not register there.
|
||||
/// </remarks>
|
||||
private static readonly string[] WindowsRunsThese =
|
||||
[
|
||||
".application", ".appref-ms", ".bat", ".chm", ".cmd", ".com", ".cpl", ".diagcab", ".exe", ".hta",
|
||||
".inf", ".jar", ".js", ".jse", ".library-ms", ".lnk", ".msc", ".msi", ".msp", ".pif", ".ps1",
|
||||
".psm1", ".py", ".pyw", ".reg", ".scf", ".scr", ".search-ms", ".settingcontent-ms", ".url",
|
||||
".vbe", ".vbs", ".website", ".ws", ".wsc", ".wsf", ".wsh", ".xll",
|
||||
];
|
||||
|
||||
/// <summary>Extensions Launch Services runs, or installs, rather than opens.</summary>
|
||||
private static readonly string[] MacRunsThese =
|
||||
[
|
||||
".app", ".command", ".dmg", ".fileloc", ".inetloc", ".jar", ".mpkg", ".pkg", ".scpt", ".terminal",
|
||||
".tool", ".webloc", ".workflow",
|
||||
];
|
||||
|
||||
/// <summary>Opens <paramref name="path"/> for editing.</summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// This desktop would run the file and has no editor to offer in its place.
|
||||
/// </exception>
|
||||
internal static Task OpenAsync(string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
|
||||
using var opened = Process.Start(StartInfoFor(path));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>What to start so that <paramref name="path"/> opens in an editor.</summary>
|
||||
internal static ProcessStartInfo StartInfoFor(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows() && WouldRunOnWindows(path))
|
||||
{
|
||||
var edit = new ProcessStartInfo(path) { UseShellExecute = true, Verb = "edit" };
|
||||
|
||||
if (edit.Verbs.Contains("edit", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return edit;
|
||||
}
|
||||
|
||||
return new ProcessStartInfo("notepad.exe") { UseShellExecute = false, ArgumentList = { path } };
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS() && HasExtension(path, MacRunsThese))
|
||||
{
|
||||
return new ProcessStartInfo("open") { UseShellExecute = false, ArgumentList = { "-t", path } };
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux() && HasExtension(path, [".desktop"]))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{Path.GetFileName(path)} is a launcher, which this desktop would run rather than open. "
|
||||
+ "Download it and open it in an editor instead.");
|
||||
}
|
||||
|
||||
return new ProcessStartInfo(path) { UseShellExecute = true };
|
||||
}
|
||||
|
||||
/// <summary>Whether Windows' default action for this file is to run it.</summary>
|
||||
internal static bool WouldRunOnWindows(string path, string? pathExt = null)
|
||||
{
|
||||
var listed = (pathExt ?? Environment.GetEnvironmentVariable("PATHEXT") ?? string.Empty)
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
return HasExtension(path, WindowsRunsThese) || HasExtension(path, listed);
|
||||
}
|
||||
|
||||
/// <summary>Whether the file's last extension is one of these, ignoring case.</summary>
|
||||
/// <remarks>
|
||||
/// Trailing dots and spaces are dropped first, because Windows drops them too: <c>run.bat.</c> is
|
||||
/// <c>run.bat</c> by the time anything acts on it.
|
||||
/// </remarks>
|
||||
private static bool HasExtension(string path, IEnumerable<string> extensions)
|
||||
{
|
||||
var extension = Path.GetExtension(Path.GetFileName(path).TrimEnd('.', ' '));
|
||||
|
||||
return extension.Length > 0
|
||||
&& extensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -78,11 +78,21 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
|
||||
private readonly ClientPaths paths;
|
||||
|
||||
/// <summary>This profile's own enclave key; see <see cref="DeviceKeyNames"/>.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="KeyTag"/> for the default profile and a tag of its own for every other, so that signing
|
||||
/// out of one installation cannot delete the key another's Touch ID unlock is sealed to. A profile
|
||||
/// registered before this is still sealed to the shared tag and is read from there until it registers
|
||||
/// again; it is never deleted from a profile that does not own it.
|
||||
/// </remarks>
|
||||
private readonly string keyTag;
|
||||
|
||||
/// <summary>Creates the store.</summary>
|
||||
public MacDeviceKeyStore(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
this.paths = paths;
|
||||
keyTag = DeviceKeyNames.For(paths, KeyTag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -163,7 +173,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
ReadOnlyMemory<byte> devicePrivateKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sealedKey = Seal(devicePrivateKey.Span)
|
||||
var sealedKey = Seal(devicePrivateKey.Span, keyTag)
|
||||
?? throw new InvalidOperationException(
|
||||
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
|
||||
|
||||
@@ -184,7 +194,8 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Unseal(sealedKey);
|
||||
// The shared tag only while this profile has no key of its own, so one unlock is one prompt.
|
||||
return Unseal(sealedKey, !string.Equals(keyTag, KeyTag, StringComparison.Ordinal) && !HasKey(keyTag) ? KeyTag : keyTag);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -202,7 +213,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
// 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);
|
||||
_ = DeleteKey(symbols, keyTag);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
@@ -212,7 +223,26 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
/// 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)
|
||||
/// <summary>Whether a key is filed under this tag, asked without using it and so without a prompt.</summary>
|
||||
private static bool HasKey(string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = new CoreFoundationScope();
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
return symbols.Complete && FindKey(scope, symbols, tag, prompt: null) != IntPtr.Zero;
|
||||
}
|
||||
catch (Exception exception) when (exception is DllNotFoundException
|
||||
or EntryPointNotFoundException
|
||||
or BadImageFormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey, string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -227,11 +257,11 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
|
||||
// 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);
|
||||
var privateKey = FindKey(scope, symbols, tag, prompt: null);
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
{
|
||||
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
|
||||
privateKey = CreateEnclaveKey(scope, symbols, tag);
|
||||
}
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
@@ -281,7 +311,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
/// on a thread that is also expected to draw the window behind it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static byte[]? Unseal(byte[] sealedKey)
|
||||
private static byte[]? Unseal(byte[] sealedKey, string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -294,7 +324,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
return null;
|
||||
}
|
||||
|
||||
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
|
||||
var privateKey = FindKey(scope, symbols, tag, ConsentPrompt);
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// The device key store this machine supports, chosen once and off the UI thread.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Choosing a store means probing for it, and on Windows the probe is creating an RSA-2048 key inside the TPM
|
||||
/// and deleting it again — see <see cref="WindowsDeviceKeyStore.IsSupported"/> — which is a slow device doing
|
||||
/// real work. It used to run synchronously on the UI thread three times over: once to choose the store at
|
||||
/// startup, again when the unlock card asked whether to offer the gesture, and again after every unlock when
|
||||
/// the security page asked whether to offer registering. A machine does not gain or lose a TPM while this
|
||||
/// process runs, so the answer is worked out once, on the thread pool, and every question after it is a
|
||||
/// lookup.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Available means the choice landed on a real store, which is what the probe that chose it established.
|
||||
/// Everything else waits for the choice and hands over to it — in practice it has long since finished,
|
||||
/// since nothing reaches for the device key until somebody is at the unlock card.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ProbedDeviceKeyStore : IDeviceKeyStore
|
||||
{
|
||||
private readonly Task<IDeviceKeyStore> chosen;
|
||||
|
||||
/// <summary>Starts choosing at once, on the thread pool.</summary>
|
||||
internal ProbedDeviceKeyStore(Func<IDeviceKeyStore> choose)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(choose);
|
||||
chosen = Task.Run(choose);
|
||||
}
|
||||
|
||||
/// <summary>The store the probe settled on, for a test that needs to know which.</summary>
|
||||
internal Task<IDeviceKeyStore> Chosen => chosen;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||
await chosen.WaitAsync(cancellationToken).ConfigureAwait(false) is not UnavailableDeviceKeyStore;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
await store.SaveAsync(devicePrivateKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await store.TryLoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
await store.ForgetAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// One desktop instance per profile, and a way for a second launch to raise the first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Per profile rather than per machine, which decides two things at once.</b> The lock lives in
|
||||
/// <see cref="ClientPaths.InstanceLockFile"/>, so a nightly and a release build never exclude each other —
|
||||
/// they are separate applications by design, ADR 0013 — and two people signed in to one machine each get
|
||||
/// their own instance. A global mutex would have had to re-derive both of those from somewhere.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why one instance at all.</b> The cache is a SQLite file with an outbox in it, and the whole design
|
||||
/// assumes one writer: <see cref="ClientPaths"/> says so about two machines sharing a profile through a
|
||||
/// cloud sync client, and two processes on one machine is the same hazard with a shorter path to it. The
|
||||
/// terminal's loopback listener is the visible half — the second process fails to bind, gets a port of its
|
||||
/// own or refuses to start a workspace at all — and the cache is the half that would quietly corrupt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A file handle rather than a named mutex</b>, because the operating system releases it when the
|
||||
/// process ends <em>however</em> it ends. A named mutex abandoned by a killed process is a state both
|
||||
/// platforms model differently and neither models simply; an exclusive handle is closed by the kernel on
|
||||
/// Windows and by the file descriptor going away on Unix, so there is no such thing as a stale lock here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>And a named pipe for the other half, because exiting silently is not an answer.</b> Somebody who
|
||||
/// double-clicks the icon and sees nothing happen has been told the application is broken. The second
|
||||
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
||||
/// what makes the exclusion invisible rather than mysterious.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A launch that carries a link passes the link on.</b> Clicking "Open in DodoSSH" in Spelunk starts
|
||||
/// this executable with a <c>dodossh://</c> link, and while DodoSSH is already running that start is a
|
||||
/// second launch like any other — so the doorbell has a second message, which carries the link to the
|
||||
/// instance that can act on it. See <see cref="OpenLinkPrefix"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SingleInstance
|
||||
{
|
||||
/// <summary>The one thing a second launch has to say.</summary>
|
||||
/// <remarks>
|
||||
/// Compared against a constant rather than parsed, for the reason <c>MainWindow</c>'s release-focus
|
||||
/// message is: one message exists and treating anything else as a command would be a wider door than
|
||||
/// this needs. Anything may connect to a per-user pipe — it is not a security boundary — so what arrives
|
||||
/// is checked rather than trusted.
|
||||
/// </remarks>
|
||||
private const string ComeForward = "dodossh.come-forward";
|
||||
|
||||
/// <summary>The other thing a second launch may say: come forward, and open this link.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Followed by the link, on the same one line. Checked as strictly as <see cref="ComeForward"/> is, and
|
||||
/// for the same reason: anything on this machine may write to the pipe, so the link is parsed again here
|
||||
/// by <see cref="SpelunkLink.TryParse"/>, and a message that is not exactly this prefix and a link that
|
||||
/// passes is ignored whole — the window does not even come forward for it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A second message rather than a second line after <see cref="ComeForward"/>, so that an older running
|
||||
/// build, which compares the whole line against that constant, ignores a link it cannot act on rather
|
||||
/// than half-obeying it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private const string OpenLinkPrefix = "dodossh.open-link ";
|
||||
|
||||
/// <summary>The longest message the doorbell reads before it stops listening to that caller.</summary>
|
||||
/// <remarks>
|
||||
/// The longest link a second launch could send, and its prefix. A caller that goes on past it is not a
|
||||
/// DodoSSH, and reading a line of whatever length it likes would let it decide how much memory this
|
||||
/// process spends on it.
|
||||
/// </remarks>
|
||||
private const int MaxMessageLength = 64 + SpelunkLink.MaxLength;
|
||||
|
||||
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
||||
/// <remarks>
|
||||
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
||||
/// not a bound on anything slow; it is how long to wait before concluding that whoever holds the lock
|
||||
/// cannot be talked to.
|
||||
/// </remarks>
|
||||
private const int AnswerTimeoutMilliseconds = 500;
|
||||
|
||||
/// <summary>How long to keep trying for a lock that is held by nobody who answers.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>This is the window an update relaunch lands in, and without it an update would look like a
|
||||
/// crash.</b> Velopack's apply ends this process and its updater then starts the new build; the two are
|
||||
/// ordered — the updater waits on the old process id — but the whole sequence is one where a lock held a
|
||||
/// moment too long would make the relaunched application exit on the spot, having apparently uninstalled
|
||||
/// itself. The same window covers an ordinary double-double-click, where the first process holds the file
|
||||
/// and has not reached its pipe yet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only reached when nobody answers the pipe. A live instance replies in milliseconds and the second
|
||||
/// launch is gone before any of this matters.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan DefaultPatience = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>How long to wait between attempts within that window.</summary>
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <summary>
|
||||
/// Claims this profile for this process, or asks whoever holds it to come forward.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A handle to hold for the life of the process, or <see langword="null"/> when another instance has it —
|
||||
/// in which case that instance has already been asked to raise its window and this process should end.
|
||||
/// </returns>
|
||||
/// <param name="paths">The profile to claim. Its directory is created if it is not there.</param>
|
||||
/// <param name="patience">
|
||||
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
||||
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
||||
/// </param>
|
||||
/// <param name="link">
|
||||
/// The link this launch was started with, if any — passed to the running instance instead of a bare
|
||||
/// request to come forward.
|
||||
/// </param>
|
||||
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null, SpelunkLink? link = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
try
|
||||
{
|
||||
paths.EnsureCreated();
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// No profile directory means no lock file, and every build before this one started anyway. What
|
||||
// follows will fail on its own terms and say something better than this could.
|
||||
return NoClaim.Instance;
|
||||
}
|
||||
|
||||
// Counted rather than clocked. Thread.Sleep is not a clock and reading one here would be pretending
|
||||
// otherwise — what this bounds is the number of attempts, and the wall time is whatever the platform
|
||||
// makes of them.
|
||||
var attemptsLeft = (int)Math.Ceiling(
|
||||
(patience ?? DefaultPatience).TotalMilliseconds / RetryDelay.TotalMilliseconds);
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (TryTake(paths.InstanceLockFile) is { } taken)
|
||||
{
|
||||
return taken;
|
||||
}
|
||||
|
||||
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
||||
// round trip on a local pipe rather than the whole window below.
|
||||
if (AskTheRunningOneToComeForward(paths, link))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (attemptsLeft-- <= 0)
|
||||
{
|
||||
// Held by something that will not talk. Giving up rather than starting anyway, because what
|
||||
// is being protected is a database with one writer, and "probably nobody" is not an answer
|
||||
// worth corrupting it for.
|
||||
return null;
|
||||
}
|
||||
|
||||
Thread.Sleep(RetryDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Answers the launches that were refused, for as long as the returned handle is held.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="comeForward"/> is invoked on a thread pool thread, because that is where a pipe
|
||||
/// accept completes. A caller that touches a window has to marshal.
|
||||
/// </remarks>
|
||||
/// <param name="paths">The profile this instance claimed.</param>
|
||||
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
||||
/// <param name="openLink">
|
||||
/// What to do with a link another launch passed on, after <paramref name="comeForward"/>; null to ignore
|
||||
/// links, which then still bring the window forward. Invoked on the same thread pool thread.
|
||||
/// </param>
|
||||
internal static IAsyncDisposable Listen(
|
||||
ClientPaths paths,
|
||||
Action comeForward,
|
||||
Action<SpelunkLink>? openLink = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(comeForward);
|
||||
|
||||
var listener = new Doorbell(PipeNameFor(paths), comeForward, openLink);
|
||||
|
||||
listener.Start();
|
||||
|
||||
return listener;
|
||||
}
|
||||
|
||||
/// <summary>Tells the instance that holds this profile to raise its window, and to open a link if given one.</summary>
|
||||
/// <returns>Whether anything was listening.</returns>
|
||||
/// <remarks>
|
||||
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
||||
/// and proves itself, and this is the part with a protocol in it.
|
||||
/// </remarks>
|
||||
internal static bool AskTheRunningOneToComeForward(ClientPaths paths, SpelunkLink? link = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
try
|
||||
{
|
||||
using var pipe = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
PipeNameFor(paths),
|
||||
PipeDirection.Out);
|
||||
|
||||
pipe.Connect(AnswerTimeoutMilliseconds);
|
||||
|
||||
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
||||
|
||||
// The link as this build writes it, rather than as it arrived: the receiver parses what a DodoSSH
|
||||
// sent, and a link that parsed here has exactly one spelling. See SpelunkLink.ToString.
|
||||
writer.WriteLine(Message(link));
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is TimeoutException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Nothing there, or nothing willing. The caller decides what that means; here it is only ever
|
||||
// "no".
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What a launch says down the pipe: come forward, with or without a link to open.</summary>
|
||||
internal static string Message(SpelunkLink? link) =>
|
||||
link is null ? ComeForward : OpenLinkPrefix + link;
|
||||
|
||||
/// <summary>
|
||||
/// Reads what a caller said: whether it is a ring at all, and the link that came with it.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// True for exactly <see cref="ComeForward"/>, with no link, and for <see cref="OpenLinkPrefix"/> followed
|
||||
/// by a link <see cref="SpelunkLink.TryParse"/> accepts, with that link. False for everything else,
|
||||
/// including a link that is almost right — the caller then does nothing at all.
|
||||
/// </returns>
|
||||
internal static bool TryRead(string? said, out SpelunkLink? link)
|
||||
{
|
||||
link = null;
|
||||
|
||||
if (said is null || said.Length > MaxMessageLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (said.StartsWith(OpenLinkPrefix, StringComparison.Ordinal)
|
||||
&& SpelunkLink.TryParse(said[OpenLinkPrefix.Length..], out var parsed))
|
||||
{
|
||||
link = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
||||
/// <remarks>
|
||||
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
||||
/// tidier and is worse: it makes the file's existence part of the protocol, so a second process opening
|
||||
/// it while the first is closing gets a handle to something on its way out. An empty file that is always
|
||||
/// there and sometimes locked has one fewer state.
|
||||
/// </remarks>
|
||||
private static IDisposable? TryTake(string lockFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileStream(
|
||||
lockFile,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None,
|
||||
bufferSize: 1,
|
||||
FileOptions.None);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Somebody else holds it. The one expected failure, and the whole point.
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// A profile this user cannot write to, which is a broken installation rather than a second
|
||||
// instance. Answered as a claim so the application starts and fails on something legible.
|
||||
return NoClaim.Instance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pipe both sides name, derived from the profile so that it is scoped exactly as the lock is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A digest of the path rather than the path itself: a pipe name may not contain a directory separator
|
||||
/// on Windows, and on Unix the name becomes a file in a temporary directory whose length is bounded.
|
||||
/// Eight bytes of SHA-256, which is not doing security work — it is making one string out of another
|
||||
/// deterministically, and <c>string.GetHashCode</c> cannot because it is randomised per process.
|
||||
/// </remarks>
|
||||
private static string PipeNameFor(ClientPaths paths)
|
||||
{
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(paths.DataDirectory));
|
||||
|
||||
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
|
||||
}
|
||||
|
||||
/// <summary>A claim over nothing, for the cases where there was nothing to claim.</summary>
|
||||
private sealed class NoClaim : IDisposable
|
||||
{
|
||||
internal static NoClaim Instance { get; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing was taken.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The accept loop that answers refused launches.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One server instance for the life of the doorbell, disconnected between callers rather than disposed
|
||||
/// and rebuilt. The loop is what makes a third and fourth launch work as well as the second.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>THE ONE INSTANCE IS THE WHOLE CORRECTNESS OF THIS CLASS ON LINUX, AND A FRESH ONE PER CALLER
|
||||
/// LOSES LAUNCHES.</b> This was written the obvious way — a <c>using</c> inside the loop, a new stream
|
||||
/// per connection — which is fine on Windows and quietly wrong on Unix, where a named pipe is a Unix
|
||||
/// domain socket. Disposing the server there closes the <em>listening</em> socket and unlinks its path,
|
||||
/// so a second launch that connected while the first was being served has its connection dropped on the
|
||||
/// floor: its <c>Connect</c> returned, its write succeeded into a buffer, and nobody ever read it. The
|
||||
/// launch was told it had rung the doorbell and the window never came forward.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Measured rather than reasoned about, in a container, because the two platforms disagree and this
|
||||
/// machine is only one of them: three rings back to back are answered three times with one instance and
|
||||
/// once with a fresh one per connection. <c>Disconnect</c> ends the conversation and keeps the socket, so
|
||||
/// there is never a moment with nothing listening.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Which makes <c>leaveOpen</c> on the reader load-bearing as well. A <see cref="StreamReader"/> disposes
|
||||
/// the stream it was given, and the stream is now the server that has to survive into the next
|
||||
/// iteration — so the reader that was harmless per-connection would close the doorbell after one ring.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class Doorbell : IAsyncDisposable
|
||||
{
|
||||
private readonly string name;
|
||||
private readonly Action comeForward;
|
||||
private readonly Action<SpelunkLink>? openLink;
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
|
||||
private Task? loop;
|
||||
|
||||
internal Doorbell(string name, Action comeForward, Action<SpelunkLink>? openLink)
|
||||
{
|
||||
this.name = name;
|
||||
this.comeForward = comeForward;
|
||||
this.openLink = openLink;
|
||||
}
|
||||
|
||||
internal void Start() => loop = RunAsync(lifetime.Token);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (loop is { } running)
|
||||
{
|
||||
// Awaited rather than abandoned, so the pipe name is free by the time this returns — which
|
||||
// matters for exactly one caller: an update that is about to relaunch this application and
|
||||
// would otherwise race its own successor for the name.
|
||||
await running.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
lifetime.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var server = new NamedPipeServerStream(
|
||||
name,
|
||||
PipeDirection.In,
|
||||
maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// leaveOpen, because this server outlives the conversation. See the remark above.
|
||||
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
||||
{
|
||||
var said = await ReadBoundedLineAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (TryRead(said, out var link))
|
||||
{
|
||||
comeForward();
|
||||
|
||||
if (link is not null)
|
||||
{
|
||||
openLink?.Invoke(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HangUp(server);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A client that connected and hung up mid-sentence. Round again: giving up here would
|
||||
// leave every later launch with nothing to talk to, which is the silent-exit behaviour
|
||||
// this class exists to avoid.
|
||||
HangUp(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one line, or null once it runs past <see cref="MaxMessageLength"/> or the caller hangs up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="StreamReader.ReadLineAsync(CancellationToken)"/> reads a line of whatever length the
|
||||
/// caller sends, and the caller is anybody on this machine. A character at a time is cheap here — the
|
||||
/// reader buffers underneath, and a real message is one short line.
|
||||
/// </remarks>
|
||||
private static async Task<string?> ReadBoundedLineAsync(
|
||||
StreamReader reader,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var line = new StringBuilder();
|
||||
var next = new char[1];
|
||||
|
||||
while (await reader.ReadAsync(next.AsMemory(), cancellationToken).ConfigureAwait(false) == 1)
|
||||
{
|
||||
if (next[0] == '\n')
|
||||
{
|
||||
return line.ToString().TrimEnd('\r');
|
||||
}
|
||||
|
||||
if (line.Length == MaxMessageLength)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
line.Append(next[0]);
|
||||
}
|
||||
|
||||
return line.Length == 0 ? null : line.ToString().TrimEnd('\r');
|
||||
}
|
||||
|
||||
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
||||
/// <remarks>
|
||||
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
||||
/// an <see cref="IOException"/> above may already have left it in, and the state a second call would
|
||||
/// find. Throwing out of the error path would take the loop with it.
|
||||
/// </remarks>
|
||||
private static void HangUp(NamedPipeServerStream server)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (server.IsConnected)
|
||||
{
|
||||
server.Disconnect();
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Already disconnected. Nothing to end.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,25 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// </remarks>
|
||||
private UpdateInfo? found;
|
||||
|
||||
/// <summary>
|
||||
/// The last thing actually fetched, kept separately from the last thing found.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Because the two can disagree, and when they do it is an apply that pays for it.</b> A check
|
||||
/// overwrites <see cref="found"/>, and the shell now checks while a build is already downloaded and
|
||||
/// waiting — that is how it replaces a pending build when the feed has moved on, and how it takes one
|
||||
/// last look on the way to a restart. Either of those can land a newer answer in <c>found</c> and then
|
||||
/// fail to fetch it, or be given up on halfway; the shell's answer is to restart with the build it
|
||||
/// already has, and <see cref="Matched"/> resolving only against <c>found</c> would have thrown at it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Which turned a slow forge into a refused restart: the offer on screen was real, the package on disk
|
||||
/// was real, and the only thing wrong was that this class had forgotten which one it was.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private UpdateInfo? fetched;
|
||||
|
||||
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -321,7 +340,23 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
|
||||
// Velopack reports progress as an Action<int> and the rest of this codebase speaks IProgress<T>,
|
||||
// so the adaptation happens here rather than leaking the older shape into the view models.
|
||||
return manager.DownloadUpdatesAsync(Matched(update), progress.Report, cancellationToken);
|
||||
return DownloadAndRememberAsync(Matched(update), progress, cancellationToken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Recorded after the await and not before, so that a download which threw leaves the previous fetch as
|
||||
/// the one this channel can still apply. See <see cref="fetched"/>.
|
||||
/// </remarks>
|
||||
private async Task DownloadAndRememberAsync(
|
||||
UpdateInfo info,
|
||||
IProgress<int> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await manager
|
||||
.DownloadUpdatesAsync(info, progress.Report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
fetched = info;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -335,21 +370,39 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
|
||||
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
|
||||
/// <see cref="CheckAsync"/> returned; a mismatch is a bug in this application rather than anything a
|
||||
/// user did, which is why it throws rather than resolving to some safe-looking default.
|
||||
/// <see cref="CheckAsync"/> returned, or what a download of that then fetched; a mismatch is a bug in
|
||||
/// this application rather than anything a user did, which is why it throws rather than resolving to
|
||||
/// some safe-looking default.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What was fetched is tried before what was found, because that is the one an apply names and the one a
|
||||
/// later check cannot take away. See <see cref="fetched"/> for how the two come to disagree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private UpdateInfo Matched(AvailableUpdate update)
|
||||
{
|
||||
if (found is not { } info
|
||||
|| !string.Equals(info.TargetFullRelease.Version.ToString(), update.Version, StringComparison.Ordinal))
|
||||
if (Names(fetched, update))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No update matching {update.Version} has been found by this channel. "
|
||||
+ "Call CheckAsync and pass back what it returned.");
|
||||
return fetched!;
|
||||
}
|
||||
|
||||
return info;
|
||||
if (Names(found, update))
|
||||
{
|
||||
return found!;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No update matching {update.Version} has been found or fetched by this channel. "
|
||||
+ "Call CheckAsync and pass back what it returned.");
|
||||
}
|
||||
|
||||
private static bool Names(UpdateInfo? info, AvailableUpdate update) =>
|
||||
info is not null
|
||||
&& string.Equals(
|
||||
info.TargetFullRelease.Version.ToString(),
|
||||
update.Version,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
@@ -41,7 +41,12 @@ namespace DodoSSH.Client.App.Platform;
|
||||
/// </remarks>
|
||||
public static class DesktopDeviceKeyStores
|
||||
{
|
||||
/// <summary>The best store this machine supports.</summary>
|
||||
/// <summary>The best store this machine supports, chosen on the thread pool.</summary>
|
||||
/// <remarks>
|
||||
/// Returns at once: the probing that decides between them is slow — a TPM key created and deleted on
|
||||
/// Windows — and runs behind <see cref="ProbedDeviceKeyStore"/> rather than here, which is on the UI
|
||||
/// thread at startup.
|
||||
/// </remarks>
|
||||
/// <param name="paths">Where a stored key lives.</param>
|
||||
/// <param name="askForPin">
|
||||
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
|
||||
@@ -57,6 +62,13 @@ public static class DesktopDeviceKeyStores
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(askForPin);
|
||||
|
||||
return new ProbedDeviceKeyStore(() => Choose(paths, askForPin));
|
||||
}
|
||||
|
||||
private static IDeviceKeyStore Choose(
|
||||
ClientPaths paths,
|
||||
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
||||
{
|
||||
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new WindowsDeviceKeyStore(paths);
|
||||
@@ -123,6 +135,10 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
/// 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 gesture had stopped working.
|
||||
/// <para>
|
||||
/// This is the default profile's key, and the name every profile shared before keys were per profile;
|
||||
/// see <see cref="DeviceKeyNames"/> and <see cref="keyName"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
||||
|
||||
@@ -133,11 +149,22 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
|
||||
private readonly ClientPaths paths;
|
||||
|
||||
/// <summary>This profile's own key.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="KeyName"/> for the default profile and a name of its own for every other — so signing out
|
||||
/// of the nightly no longer deletes the key the release build's device unlock is sealed to. A profile
|
||||
/// registered before this still has its blob sealed to the shared key, and <see cref="TryLoadAsync"/>
|
||||
/// keeps opening it there until the next registration re-seals it here. It is never deleted from a
|
||||
/// profile that does not own it, which is the half of this that matters.
|
||||
/// </remarks>
|
||||
private readonly string keyName;
|
||||
|
||||
/// <summary>Creates the store.</summary>
|
||||
public WindowsDeviceKeyStore(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
this.paths = paths;
|
||||
keyName = DeviceKeyNames.For(paths, KeyName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -182,7 +209,7 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
ReadOnlyMemory<byte> devicePrivateKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var key = OpenOrCreate();
|
||||
using var key = OpenOrCreate(keyName);
|
||||
using var rsa = new RSACng(key);
|
||||
|
||||
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
|
||||
@@ -204,7 +231,13 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Unseal(sealedKey);
|
||||
// The shared key only while this profile has none of its own: a profile registered before keys were
|
||||
// per profile is still sealed to it. Asking both would show two consent dialogs for one unlock.
|
||||
var sealedTo = !string.Equals(keyName, KeyName, StringComparison.Ordinal) && !CngKey.Exists(keyName, new CngProvider(PlatformProvider))
|
||||
? KeyName
|
||||
: keyName;
|
||||
|
||||
return Unseal(sealedKey, sealedTo);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -215,9 +248,11 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
File.Delete(paths.DeviceKeyFile);
|
||||
}
|
||||
|
||||
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
||||
// This profile's key and never the shared one, unless this is the profile that owns it. Another
|
||||
// profile's device unlock may still be sealed to it.
|
||||
if (CngKey.Exists(keyName, new CngProvider(PlatformProvider)))
|
||||
{
|
||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
||||
using var key = CngKey.Open(keyName, new CngProvider(PlatformProvider));
|
||||
key.Delete();
|
||||
}
|
||||
|
||||
@@ -231,16 +266,16 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
/// user and all have the same remedy, so none of them are worth telling apart here — see
|
||||
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
||||
/// </remarks>
|
||||
private static byte[]? Unseal(byte[] sealedKey)
|
||||
private static byte[]? Unseal(byte[] sealedKey, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
||||
if (!CngKey.Exists(name, new CngProvider(PlatformProvider)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
||||
using var key = CngKey.Open(name, new CngProvider(PlatformProvider));
|
||||
using var rsa = new RSACng(key);
|
||||
|
||||
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
|
||||
@@ -256,18 +291,18 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
|
||||
/// silently is the one behaviour this whole file exists to prevent.
|
||||
/// </remarks>
|
||||
private static CngKey OpenOrCreate()
|
||||
private static CngKey OpenOrCreate(string name)
|
||||
{
|
||||
var provider = new CngProvider(PlatformProvider);
|
||||
|
||||
if (CngKey.Exists(KeyName, provider))
|
||||
if (CngKey.Exists(name, provider))
|
||||
{
|
||||
return CngKey.Open(KeyName, provider);
|
||||
return CngKey.Open(name, provider);
|
||||
}
|
||||
|
||||
return CngKey.Create(
|
||||
CngAlgorithm.Rsa,
|
||||
KeyName,
|
||||
name,
|
||||
new CngKeyCreationParameters
|
||||
{
|
||||
Provider = provider,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Runtime.Versioning;
|
||||
using DodoSSH.Client.Session;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Registers this executable as the handler for <c>dodossh://</c> links, for this user, on Windows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Under HKCU\Software\Classes, because the install is per user.</b> Velopack installs into the user's
|
||||
/// own profile with no elevation, and a handler registered machine-wide would need the elevation the
|
||||
/// installer deliberately never asks for. Windows merges this key into <c>HKEY_CLASSES_ROOT</c> for the
|
||||
/// user, which is all a browser consults.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Written by Velopack's install and update hooks, removed by its uninstall hook</b> — see
|
||||
/// <c>Program.Main</c>. An update rewrites it because the first update to a build that has this is how an
|
||||
/// existing installation gets it at all, and because it costs nothing to say again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Removed only when it is still ours.</b> The release and the nightly are separate installations that
|
||||
/// both answer <c>dodossh://</c>, and the last one installed holds the key. Uninstalling the other must not
|
||||
/// take the handler away from the one that is still there.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class WindowsUrlScheme
|
||||
{
|
||||
private const string KeyPath = @"Software\Classes\" + SpelunkLink.Scheme;
|
||||
private const string CommandPath = @"shell\open\command";
|
||||
|
||||
/// <summary>Points <c>dodossh://</c> at <paramref name="executable"/>.</summary>
|
||||
internal static void Register(string executable)
|
||||
{
|
||||
using var scheme = Registry.CurrentUser.CreateSubKey(KeyPath);
|
||||
|
||||
scheme.SetValue(string.Empty, "URL:DodoSSH");
|
||||
|
||||
// Empty, and its presence is the whole of it: this value is what makes the key a URL scheme rather
|
||||
// than a file type.
|
||||
scheme.SetValue("URL Protocol", string.Empty);
|
||||
|
||||
using (var icon = scheme.CreateSubKey("DefaultIcon"))
|
||||
{
|
||||
icon.SetValue(string.Empty, $"\"{executable}\",0");
|
||||
}
|
||||
|
||||
using var command = scheme.CreateSubKey(CommandPath);
|
||||
|
||||
command.SetValue(string.Empty, CommandFor(executable));
|
||||
}
|
||||
|
||||
/// <summary>Takes the handler away, if it is still <paramref name="executable"/>'s.</summary>
|
||||
internal static void Unregister(string executable)
|
||||
{
|
||||
string? registered;
|
||||
|
||||
using (var command = Registry.CurrentUser.OpenSubKey($@"{KeyPath}\{CommandPath}"))
|
||||
{
|
||||
registered = command?.GetValue(string.Empty) as string;
|
||||
}
|
||||
|
||||
if (IsOurs(registered, executable))
|
||||
{
|
||||
Registry.CurrentUser.DeleteSubKeyTree(KeyPath, throwOnMissingSubKey: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The command Windows runs for a link: this executable, with the link as its one argument.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves quoted. The path has spaces in it on any profile whose user name does, and the link is
|
||||
/// handed over whole as <c>%1</c> — which Windows quotes into one argument only because it is quoted
|
||||
/// here. Program.Main then reads it back and checks it before anything acts on it.
|
||||
/// </remarks>
|
||||
internal static string CommandFor(string executable) => $"\"{executable}\" \"%1\"";
|
||||
|
||||
/// <summary>Whether a registered command is the one <see cref="Register"/> writes for this executable.</summary>
|
||||
internal static bool IsOurs(string? registered, string executable) =>
|
||||
string.Equals(registered, CommandFor(executable), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -8,6 +8,16 @@ namespace DodoSSH.Client.App;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>dodossh://</c> link this process was started with, once it has claimed the profile; null for an
|
||||
/// ordinary launch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A static handed across to <c>DodoSshApp</c> rather than a constructor argument, because Avalonia
|
||||
/// constructs the application itself — and the designer does too, through <see cref="BuildAvaloniaApp"/>,
|
||||
/// with no arguments to have.
|
||||
/// </remarks>
|
||||
internal static SpelunkLink? LaunchLink { get; private set; }
|
||||
/// <summary>
|
||||
/// Entry point.
|
||||
/// </summary>
|
||||
@@ -29,17 +39,126 @@ internal static class Program
|
||||
// and a silent installer run that reached either would be a background process holding the cache
|
||||
// file open during the very file operations the installer is performing.
|
||||
//
|
||||
// There are deliberately no OnFirstRun or OnAfterUpdate hooks. A hook process has no passphrase,
|
||||
// so the cache is bytes it cannot read, and the one thing that would want doing after an update —
|
||||
// a schema migration — already runs on every ordinary launch from MainWindowViewModel.StartAsync,
|
||||
// before unlock and touching no encrypted content.
|
||||
VelopackApp.Build().Run();
|
||||
// There are deliberately no OnFirstRun or OnAfterUpdate hooks that touch the profile. A hook process
|
||||
// has no passphrase, so the cache is bytes it cannot read, and the one thing that would want doing
|
||||
// after an update — a schema migration — already runs on every ordinary launch from
|
||||
// MainWindowViewModel.StartAsync, before unlock and touching no encrypted content. The hooks it does
|
||||
// have touch only the registry; see RunVelopack.
|
||||
RunVelopack();
|
||||
|
||||
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||
|
||||
/*
|
||||
One instance per profile, and nothing above this line is affected by it: the installer's own runs
|
||||
have already exited inside VelopackApp.Run, and the WebView2 environment variable is a property of
|
||||
this process rather than of the application.
|
||||
|
||||
Claimed here rather than after Avalonia is configured, so that a second launch starts nothing — no
|
||||
toolkit, no window, no splash flashing on screen before it thinks better of it. What it does
|
||||
instead is ask the running instance to come forward, which is why this is not simply an exit.
|
||||
|
||||
The handle is held for as long as Main runs, which is as long as the message loop does. See
|
||||
SingleInstance for why it is a file rather than a mutex, and for what the profile scoping buys.
|
||||
*/
|
||||
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||
|
||||
// Read before the claim, because a launch that carries a link and finds DodoSSH already running
|
||||
// passes the link on rather than merely asking for the window.
|
||||
var link = LinkFrom(args);
|
||||
|
||||
using var claim = SingleInstance.Claim(paths, link: link);
|
||||
|
||||
if (claim is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LaunchLink = link;
|
||||
|
||||
// After the claim, so a second launch that only hands its link over writes nothing.
|
||||
CrashLog.Install(paths);
|
||||
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
/// <summary>Lets Velopack do its install bookkeeping, with this application's three hooks.</summary>
|
||||
/// <remarks>
|
||||
/// The hooks touch only the registry: they make this executable the handler for <c>dodossh://</c> links,
|
||||
/// which is how Spelunk's "Open in DodoSSH" reaches it, and take that away again on uninstall. Velopack
|
||||
/// runs them on Windows only, and offers them only there; the other desktops declare the scheme in their
|
||||
/// packages instead — build/macos's Info.plist template and build/linux's desktop entry. See
|
||||
/// <see cref="WindowsUrlScheme"/>.
|
||||
/// </remarks>
|
||||
private static void RunVelopack()
|
||||
{
|
||||
var velopack = VelopackApp.Build();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
velopack = velopack
|
||||
.OnAfterInstallFastCallback(_ => RegisterUrlScheme())
|
||||
.OnAfterUpdateFastCallback(_ => RegisterUrlScheme())
|
||||
.OnBeforeUninstallFastCallback(_ => UnregisterUrlScheme());
|
||||
}
|
||||
|
||||
velopack.Run();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>dodossh://</c> link among this launch's arguments, if there is one and it passes the checks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A link that fails them is dropped and the launch goes ahead as an ordinary one: the user clicked
|
||||
/// something that meant "open DodoSSH", and a window is still the right answer to that even when what
|
||||
/// came with it cannot be trusted. See <see cref="SpelunkLink.TryParse"/> for what is checked.
|
||||
/// </remarks>
|
||||
private static SpelunkLink? LinkFrom(string[] args) =>
|
||||
args.FirstOrDefault(SpelunkLink.LooksLikeOne) is { } candidate
|
||||
&& SpelunkLink.TryParse(candidate, out var link)
|
||||
? link
|
||||
: null;
|
||||
|
||||
/// <summary>Makes this executable the handler for <c>dodossh://</c> links, where that is a registry key.</summary>
|
||||
/// <remarks>
|
||||
/// Failures are swallowed. This runs inside Velopack's install, and an installer that failed because a
|
||||
/// convenience could not be set up would be the wrong trade — the application works without it, and the
|
||||
/// only thing lost is Spelunk's button opening it.
|
||||
/// </remarks>
|
||||
private static void RegisterUrlScheme()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WindowsUrlScheme.Register(executable);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
||||
{
|
||||
// See the remarks.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="RegisterUrlScheme" path="/remarks" />
|
||||
private static void UnregisterUrlScheme()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WindowsUrlScheme.Unregister(executable);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
||||
{
|
||||
// See the remarks on RegisterUrlScheme.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Tells a drawn titlebar's double-click from a press that starts a drag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both titlebars used to answer a double-click through <c>DoubleTapped</c> and start a drag on every press.
|
||||
/// The two do not mix: <c>BeginMoveDrag</c> hands the press to the operating system's own move loop, which
|
||||
/// eats the release — and a double tap is a gesture Avalonia assembles from presses <em>and</em> releases,
|
||||
/// so the second click often never completed one and the window did not maximise. The convention it was
|
||||
/// imitating is one nobody thinks about until it does not work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the press decides, before the drag begins: a second press inside the platform's double-click time and
|
||||
/// distance is the maximise, and does not start a drag at all. The click count is honoured where the platform
|
||||
/// reports one, and the time and distance are measured here for where the move loop kept it from counting.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class CaptionPresses
|
||||
{
|
||||
private ulong lastAt;
|
||||
|
||||
private Point lastWhere;
|
||||
|
||||
private bool waitingForASecond;
|
||||
|
||||
/// <summary>Whether this press is the second of a double-click on <paramref name="bar"/>.</summary>
|
||||
internal bool IsSecond(PointerPressedEventArgs e, Visual bar)
|
||||
{
|
||||
var settings = Application.Current?.PlatformSettings;
|
||||
var time = settings?.GetDoubleTapTime(e.Pointer.Type) ?? TimeSpan.FromMilliseconds(500);
|
||||
var size = settings?.GetDoubleTapSize(e.Pointer.Type) ?? new Size(4, 4);
|
||||
var where = e.GetPosition(bar);
|
||||
|
||||
var second = e.ClickCount == 2
|
||||
|| (waitingForASecond
|
||||
&& e.Timestamp - lastAt <= (ulong)time.TotalMilliseconds
|
||||
&& Math.Abs(where.X - lastWhere.X) <= size.Width
|
||||
&& Math.Abs(where.Y - lastWhere.Y) <= size.Height);
|
||||
|
||||
// A double-click spends both presses: a third that follows quickly starts again rather than toggling
|
||||
// the window straight back.
|
||||
waitingForASecond = !second;
|
||||
lastAt = e.Timestamp;
|
||||
lastWhere = where;
|
||||
|
||||
return second;
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
||||
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
@@ -12,5 +13,10 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// </remarks>
|
||||
internal sealed partial class ConfirmDeleteCard : UserControl
|
||||
{
|
||||
public ConfirmDeleteCard() => InitializeComponent();
|
||||
public ConfirmDeleteCard()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
SafeAnswerFocus.Follow(this, nameof(VaultViewModel.PendingDeletion), () => CancelButton);
|
||||
}
|
||||
}
|
||||
@@ -111,13 +111,33 @@
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
||||
Margin="24">
|
||||
|
||||
<!--
|
||||
Which machine, and at what address. Both selectable, for the reason the session column's own address
|
||||
is — see SessionSidebar.axaml: a TextBlock in Avalonia is a drawing of a string rather than a string,
|
||||
and this pair is the one place the shell says what it is dialling *before* anything has answered.
|
||||
That is exactly when the value is wanted somewhere else: in a ticket, in a message to whoever owns
|
||||
the host, in the ssh command being written in the window beside this one. It is also the moment it is
|
||||
least available anywhere else, because there is no prompt yet and may never be one — a refused
|
||||
connection leaves this card holding the only copy on screen.
|
||||
|
||||
Both rather than only the name. They are two lines of one identifier, and a pair where the first can
|
||||
be taken and the second cannot is a worse answer than either.
|
||||
|
||||
The trimming stays, and the tooltips are what make it safe rather than a trap. A drag reaches only
|
||||
the glyphs that were drawn, so a name or an address wearing an ellipsis has to be taken with
|
||||
select-all — Ctrl+A, then Ctrl+C — which takes the whole value, because the ellipsis is a drawing and
|
||||
not a shorter string. The hover says what the whole value is, so nobody copies half of one without
|
||||
having been told there is a half. Same argument, same shape, as the sidebar's.
|
||||
-->
|
||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||
HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding SelectedTab.Label}" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding SelectedTab.Address}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Width="420" SizeToContent="Height"
|
||||
CanResize="False" ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource Surface}"
|
||||
Background="{StaticResource Panel}"
|
||||
Title="DodoSSH">
|
||||
|
||||
<!--
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
|
||||
<TextBlock Foreground="{StaticResource TextDim}" FontSize="12" TextWrapping="Wrap"
|
||||
x:Name="Explanation" />
|
||||
|
||||
<!--
|
||||
|
||||
@@ -39,7 +39,10 @@ public sealed partial class DevicePinWindow : Window
|
||||
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
|
||||
public DevicePinWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
// InitializeComponent, not AvaloniaXamlLoader.Load: only the generated method assigns the x:Name
|
||||
// fields below, and with Load the first of them was null — so this window threw on construction and
|
||||
// no Linux PIN prompt was ever shown.
|
||||
InitializeComponent();
|
||||
|
||||
Accept.Click += OnAccept;
|
||||
Cancel.Click += OnCancel;
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:import="using:DodoSSH.Client.Import"
|
||||
x:Class="DodoSSH.Client.App.Views.HetznerImportScreen"
|
||||
x:DataType="vm:HetznerImportViewModel">
|
||||
|
||||
<!--
|
||||
Importing the servers in a Hetzner Cloud project.
|
||||
|
||||
Drawn over the Preferences page inside settings mode, exactly as ImportScreen.axaml is — SettingsNav
|
||||
stays lit on Preferences, the titlebar says "Back to preferences", and neither is reachable from the nav
|
||||
rail. See MainWindowViewModel.IsHetznerImportOpen.
|
||||
|
||||
── WHY THIS ONE IS A WIZARD AND THE ssh_config IMPORTER IS NOT ────────────────────────────────────────
|
||||
That screen reads a file that is already on this machine, so it can scan the moment it opens and show a
|
||||
list. This one cannot: it needs a credential that has to be made in somebody else's console first, and
|
||||
then it makes a request over the internet. Those are two separate moments and the screen is honest about
|
||||
it — step one asks for the token, step two shows what the project holds, step three says what was stored.
|
||||
Both of the first two write nothing.
|
||||
|
||||
── THE TOKEN IS NOT STORED ────────────────────────────────────────────────────────────────────────────
|
||||
It is used for one request and cleared, whether the read succeeded or not. Nothing here writes it to the
|
||||
vault, to settings or to a log, and the next import asks again — the same argument ImportScreen's key
|
||||
tick makes for not remembering a "yes". The note above the box says which permission is needed (Read),
|
||||
that a token is one project rather than one account, and that dedicated servers behind Hetzner's Robot
|
||||
are not covered by any of this.
|
||||
|
||||
── NOTHING IMPORTED HERE CONNECTS ON ITS OWN ──────────────────────────────────────────────────────────
|
||||
Hetzner has never held the private half of an SSH key, so no key can come with these hosts. That is said
|
||||
above the button rather than after it, on the same reasoning ImportScreen.axaml gives for its own version
|
||||
of this sentence: somebody who is not told will conclude the import was broken.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
<!-- Matching ImportScreen's table exactly: this is the same table about a different source. -->
|
||||
<Style Selector="Border.importrow:pointerover">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.new">
|
||||
<Setter Property="Background" Value="{StaticResource LiveWash}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.new > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.exists">
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.exists > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.warn">
|
||||
<Setter Property="Background" Value="{StaticResource WarnWash}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.warn > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch" RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<!-- ============ HEADER ============ -->
|
||||
<StackPanel Grid.Row="0" Spacing="10">
|
||||
<TextBlock Classes="settingstitle" Text="Import from Hetzner Cloud" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="label" Text="{Binding StepLabel}" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding StepTitle}" FontSize="12"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="0,12,0,0"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<Panel Grid.Row="2" Margin="0,18,0,0">
|
||||
|
||||
<!-- ============ STEP 1 · THE TOKEN ============ -->
|
||||
<Grid IsVisible="{Binding IsTokenStep}" RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Text="Cloud API token" FontSize="13.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
|
||||
Text="{Binding TokenNote}" />
|
||||
<TextBox Text="{Binding Token}" PasswordChar="•" Height="42" FontSize="13"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
PlaceholderText="64 characters, from Security → API tokens">
|
||||
<!-- Enter reads the project, so a pasted token does not need the mouse to go anywhere. -->
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<Button Classes="ghost" Height="42" Content="CANCEL" Command="{Binding CancelCommand}"
|
||||
ToolTip.Tip="Back to Preferences. Nothing has been sent." />
|
||||
<Button Classes="accent" Height="42" Content="READ THE PROJECT"
|
||||
Command="{Binding ScanCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Lists the project's servers. Nothing is stored, and the token is forgotten as soon as the list arrives." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ STEP 2 · WHAT THE PROJECT HOLDS ============ -->
|
||||
<Grid IsVisible="{Binding IsChooseStep}" RowDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<!--
|
||||
What every imported host gets: which address, which login user, which port. One set of answers for
|
||||
the whole import rather than one per row, because an estate is reached the same way throughout —
|
||||
a per-server dropdown would be forty answers to one question.
|
||||
-->
|
||||
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="20,18">
|
||||
<StackPanel Spacing="14">
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="DIAL" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<!--
|
||||
Chips rather than radio buttons: this application has no RadioButton style, and Fluent's
|
||||
own beside a row of chips reads as belonging to a different program. Button.chiptoggle is
|
||||
the tag picker's control, which is already "one of these is on" drawn as a chip.
|
||||
-->
|
||||
<Button Classes="chiptoggle" Content="PUBLIC IPv4"
|
||||
Classes.worn="{Binding UsesPublicIPv4}"
|
||||
Command="{Binding UseAddressCommand}"
|
||||
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv4}" />
|
||||
<Button Classes="chiptoggle" Content="PUBLIC IPv6"
|
||||
Classes.worn="{Binding UsesPublicIPv6}"
|
||||
Command="{Binding UseAddressCommand}"
|
||||
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv6}" />
|
||||
<Button Classes="chiptoggle" Content="PRIVATE NETWORK"
|
||||
Classes.worn="{Binding UsesPrivate}"
|
||||
Command="{Binding UseAddressCommand}"
|
||||
CommandParameter="{x:Static import:HetznerAddressKind.Private}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="USER" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" Margin="14,0,0,0" />
|
||||
<TextBox Grid.Column="3" Text="{Binding Username}" Width="120" Height="34"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
|
||||
ToolTip.Tip="Every Hetzner Linux image puts the project's SSH keys on root and creates no other account." />
|
||||
<TextBox Grid.Column="4" Text="{Binding PortText}" Width="66" Height="34"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
|
||||
ToolTip.Tip="The SSH port every imported host gets." />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ WHERE THEY LAND, AND WHAT THEY AUTHENTICATE WITH. The two answers this import used to
|
||||
decide on somebody's behalf: the active vault, and nothing.
|
||||
|
||||
A project belongs to a team more often than to a person, so the vault is asked rather than
|
||||
assumed — see HetznerImportViewModel.VaultChoices, and the host editor's own picker for why
|
||||
this is its own selection rather than the keychain screen's standing preference. It is hidden
|
||||
at one vault, which is a question with one answer.
|
||||
|
||||
The key is the half Hetzner cannot supply and the keychain usually already has: the public
|
||||
half went to Hetzner when the servers were made, the private half never did. Pointing forty
|
||||
hosts at it here is one answer; without it, it was forty visits to forty host editors. The
|
||||
picker is hidden entirely on a keychain with no keys in it.
|
||||
-->
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="18">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasVaultChoice}">
|
||||
<TextBlock Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Width="230" Height="34" ItemsSource="{Binding VaultChoices}"
|
||||
SelectedItem="{Binding SelectedVault}"
|
||||
ToolTip.Tip="Which vault the imported hosts are written into. A shared one is readable by everybody who holds its key.">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasKeyChoice}">
|
||||
<TextBlock Classes="label" Text="KEY" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Width="260" Height="34" ItemsSource="{Binding KeyChoices}"
|
||||
SelectedItem="{Binding SelectedKey}"
|
||||
ToolTip.Tip="A key already in your keychain, which every imported host is pointed at. Nothing is copied — the host stores the key's id, exactly as one bound by hand does.">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HetznerKeyChoice">
|
||||
<TextBlock Text="{Binding Label}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17" TextWrapping="Wrap"
|
||||
Text="{Binding AddressNote}" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" IsVisible="{Binding !IsPortValid}"
|
||||
Foreground="{StaticResource WarnText}"
|
||||
Text="That is not a port. Give a number between 1 and 65535." />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- What the project could not be made to say, at project level. -->
|
||||
<Border Grid.Row="1" Margin="0,14,0,0" Padding="14,10" CornerRadius="10"
|
||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||
BorderThickness="1" IsVisible="{Binding HasWarnings}">
|
||||
<ItemsControl ItemsSource="{Binding Warnings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11.5"
|
||||
TextWrapping="Wrap" Margin="0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<!-- ============ THE TABLE ============ -->
|
||||
<Border Grid.Row="2" Margin="0,14,0,0" CornerRadius="12" Background="{StaticResource Pane}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" ClipToBounds="True"
|
||||
IsVisible="{Binding HasRows}">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*">
|
||||
<!-- A Button rather than a CheckBox, for the reason ImportScreen.axaml's own header gives. -->
|
||||
<Button Grid.Column="0" Classes="flat" Width="18" Height="18" Padding="0"
|
||||
VerticalAlignment="Center" Command="{Binding ToggleAllCommand}"
|
||||
ToolTip.Tip="Tick or untick every row this address choice can reach">
|
||||
<Panel Width="18" Height="18">
|
||||
<Border CornerRadius="5" Background="{StaticResource Accent}"
|
||||
IsVisible="{Binding AllTicked}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="13"
|
||||
Foreground="White" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Border CornerRadius="5" BorderBrush="{StaticResource BorderMid}" BorderThickness="1.5"
|
||||
IsVisible="{Binding !AllTicked}" />
|
||||
</Panel>
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="SERVER" FontSize="9.5" LetterSpacing="1"
|
||||
Margin="14,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="5" Classes="label" Text="WHAT THIS MEANS" FontSize="9.5"
|
||||
LetterSpacing="1" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
|
||||
<ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.Template>
|
||||
<ControlTemplate>
|
||||
<ScrollViewer>
|
||||
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
|
||||
</ScrollViewer>
|
||||
</ControlTemplate>
|
||||
</ItemsControl.Template>
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HetznerRowViewModel">
|
||||
<Border Classes="importrow" CornerRadius="8">
|
||||
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*" Margin="12" MinHeight="20">
|
||||
<!--
|
||||
Disabled where the address choice cannot reach this server, which is the private
|
||||
case: a server on no private network is not importable and must not be silently
|
||||
given a public address instead.
|
||||
-->
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}"
|
||||
IsEnabled="{Binding CanImport}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="14,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Kind}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="5" Classes="meaningchip" CornerRadius="5" Padding="8,3"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
Classes.new="{Binding IsMeaningNew}"
|
||||
Classes.exists="{Binding IsMeaningExisting}"
|
||||
Classes.warn="{Binding IsMeaningWarned}" ToolTip.Tip="{Binding Meaning}">
|
||||
<TextBlock Text="{Binding Meaning}" FontSize="10" FontWeight="SemiBold"
|
||||
MaxWidth="230" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ FOOTER ============ -->
|
||||
<StackPanel Grid.Row="3" Margin="0,16,0,0" Spacing="14">
|
||||
|
||||
<!--
|
||||
Said before the button. Whether a credential comes with the host is the difference between a
|
||||
bookmark that connects and one that asks for a password, and nothing about a Hetzner project can
|
||||
supply one.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="{Binding AuthenticationNote}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="hint" FontSize="12" VerticalAlignment="Center"
|
||||
Text="{Binding SelectionSummary}" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
|
||||
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN"
|
||||
Command="{Binding RestartCommand}"
|
||||
ToolTip.Tip="Back to the token box, for another project. This list goes with it — the token it came from was not kept." />
|
||||
<Button Classes="ghost" Height="44" Content="CANCEL" Command="{Binding CancelCommand}"
|
||||
ToolTip.Tip="Back to Preferences. Nothing is stored." />
|
||||
<Button Classes="accent" Height="44" Content="{Binding ImportLabel}"
|
||||
Command="{Binding ImportCommand}" IsEnabled="{Binding CanImport}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ STEP 3 · WHAT WAS STORED ============ -->
|
||||
<Grid IsVisible="{Binding IsDoneStep}" RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="What happens next" FontSize="13.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
|
||||
Text="{Binding AuthenticationNote}" />
|
||||
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
|
||||
Text="The list this came from is one press away. Going back to it and importing the rest costs nothing — what was just stored is marked as already here. It is the list this scan produced, though, not a fresh one: a server made in the Hetzner console since then is not in it." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right"
|
||||
Margin="0,16,0,0">
|
||||
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN" Command="{Binding RestartCommand}"
|
||||
ToolTip.Tip="Back to the token box, for another project." />
|
||||
<Button Classes="ghost" Height="44" Content="BACK TO THE LIST"
|
||||
Command="{Binding ReviewCommand}"
|
||||
ToolTip.Tip="The same servers, with what you just stored marked as already here." />
|
||||
<Button Classes="accent" Height="44" Content="DONE" Command="{Binding CancelCommand}"
|
||||
ToolTip.Tip="Back to Preferences." />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,25 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Importing hosts from a Hetzner Cloud project.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A task rather than a destination, reached from Preferences and not from the nav rail, and drawn inside
|
||||
/// settings mode over the Preferences page — the same shape <see cref="ImportScreen"/> has, for the same
|
||||
/// reasons, including having no keyboard target of its own to hand back.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No code behind it, unlike <see cref="ImportScreen"/>, and the difference is worth a sentence: that screen
|
||||
/// listens for bubbled tick events to keep its footer count honest, because its rows have no way to reach the
|
||||
/// list they are in. This one's view model subscribes to each row's own change notification instead — see
|
||||
/// <c>HetznerImportViewModel.OnRowChanged</c> — which puts the same wiring where the count actually lives
|
||||
/// rather than in a routed-event handler on a control.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class HetznerImportScreen : UserControl
|
||||
{
|
||||
public HetznerImportScreen() => InitializeComponent();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:dom="using:DodoSSH.Client.Domain"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.HostDrawer"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
@@ -385,6 +386,33 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
============ FORWARDS (read-only) ============
|
||||
QUICK ACCESS's shape, and hidden when empty for the same reason: a card with nothing under it
|
||||
answers a question nobody asked here.
|
||||
-->
|
||||
<Border Classes="section" IsVisible="{Binding SelectedHost.HasForwards}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="sectionhead" Text="Port forwards" Margin="0,0,0,2" />
|
||||
|
||||
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="13"
|
||||
Foreground="{StaticResource TextFaint}" Width="16" VerticalAlignment="Top"
|
||||
Margin="0,3,0,0" Text="" />
|
||||
<ItemsControl Grid.Column="1" ItemsSource="{Binding SelectedHost.Host.PortForwards}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="dom:PortForwardRule">
|
||||
<TextBlock Classes="mono" Text="{Binding Notation}" FontSize="12"
|
||||
TextTrimming="CharacterEllipsis" Margin="0,0,0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Double-clicking the card does the same as CONNECT. The terminal opens as a tab in the strip above and stays there while you look at anything else." />
|
||||
|
||||
@@ -550,7 +578,8 @@
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,78">
|
||||
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" />
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40"
|
||||
IsEnabled="{Binding EditorAsksForLogin}" />
|
||||
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
||||
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
||||
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
||||
@@ -571,6 +600,7 @@
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||
IsEnabled="{Binding EditorAsksForLogin}"
|
||||
HorizontalAlignment="Stretch" Height="40">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
@@ -584,6 +614,15 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Why the two controls above are greyed out for a host Spelunk signs in to: disabled rather
|
||||
than hidden, so a login somebody chose is still there to see and comes back if the box in
|
||||
the Spelunk card is unticked before saving. See VaultViewModel.EditorAsksForLogin.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !EditorAsksForLogin}"
|
||||
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
|
||||
|
||||
<!--
|
||||
Making a credential without leaving the host. The moment one is wanted is this one: somebody
|
||||
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
||||
@@ -636,16 +675,15 @@
|
||||
|
||||
<!--
|
||||
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
|
||||
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
||||
exactly what it was, "(not wired up yet)" included: the design's own wording implies a relay
|
||||
this client can dial, and it cannot. See the remark under the hint below, which is unchanged
|
||||
and still the reason this box says what it says.
|
||||
title beside it rather than under it. The "(not wired up yet)" this title carried while the
|
||||
client had no path to the relay is gone, because it has one: a relayed host is connected
|
||||
through a ticket, a WebSocket and a loopback bridge. See docs/reaching-a-host-you-cannot-dial.md.
|
||||
-->
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}" VerticalContentAlignment="Center">
|
||||
<TextBlock Text="Connect through the server relay (not wired up yet)" FontSize="13"
|
||||
<TextBlock Text="Connect through the server relay" FontSize="13"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
@@ -655,19 +693,73 @@
|
||||
to resolve the target, or it becomes an authenticated open proxy into the operator's
|
||||
network.
|
||||
|
||||
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay
|
||||
is built and this client has no path to it: VaultViewModel dials the address directly
|
||||
whether this is ticked or not. So the box as it stood spent the one deliberate privacy
|
||||
concession in the design — the address in plaintext columns — and delivered a
|
||||
connection that failed exactly as it had before, with nothing saying why.
|
||||
|
||||
Left tickable rather than disabled, because a host that already carries the flag has to
|
||||
be able to lose it, and a disabled control would trap the concession on. See
|
||||
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
|
||||
removable.
|
||||
It also says the two things a user would otherwise find out by failing: the server,
|
||||
not this machine, has to be able to reach the address — the relay is no help against a
|
||||
deployment outside the target's network — and a relayed host does not fall back to
|
||||
dialling directly, so it connects only while the server is reachable.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11.5"
|
||||
Text="Not built yet: this client always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it does work, the relay will dial on your behalf — which is why the address and port have to be stored in plain text. Everything else about the host stays encrypted either way." />
|
||||
Text="The server dials this host on your behalf, for a machine this computer cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in plain text; everything else about the host stays encrypted. A relayed host connects only while you are online." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ THE SPELUNK CARD, the relay card's twin and its alternative: ticking one unticks the other,
|
||||
because a host has one route. A host ticked here is reached through the Spelunk agent already
|
||||
on the server, so it needs no open SSH port at all. The fields appear only when the box is
|
||||
ticked, and the key is a keychain credential — picked, never typed here — so it is encrypted
|
||||
and synced like every other secret. See VaultViewModel.Spelunk.cs.
|
||||
-->
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" VerticalContentAlignment="Center">
|
||||
<TextBlock Text="Connect through Spelunk" FontSize="13"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<StackPanel Spacing="6" IsVisible="{Binding EditorThroughSpelunk}">
|
||||
<TextBox Text="{Binding EditorSpelunkApi}" Height="36"
|
||||
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||
<TextBox Text="{Binding EditorSpelunkServerId}" Height="36"
|
||||
PlaceholderText="Spelunk server id" />
|
||||
<!--
|
||||
Saved Spelunk keys first (Settings → Security), each qualified by the one address it
|
||||
goes to, then credentials whose password is a key. A deleted one keeps a placeholder, as
|
||||
the authentication picker's does, so an unrelated edit cannot drop it. Typing an address
|
||||
a key is saved for picks that key when nothing is picked yet.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10.5" Text="Spelunk API key" />
|
||||
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
|
||||
SelectedItem="{Binding EditorSelectedSpelunkKey}"
|
||||
HorizontalAlignment="Stretch" Height="40">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<!--
|
||||
Spelunk signs the user in: a key pair made in memory for each connection, its public
|
||||
half sent with the ticket, and the agent's one-connection SSH server accepting only
|
||||
that. Ticking it greys out the login above. See SpelunkRoute.SignInThroughSpelunk.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" VerticalContentAlignment="Center">
|
||||
<TextBlock Text="Sign in through Spelunk (no SSH key needed)" FontSize="12.5"
|
||||
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
<!--
|
||||
What the route asks for and what it costs, said where the decision is made: an Admin-role
|
||||
Spelunk API key, sent to the address above and nowhere else, and a host that is reached
|
||||
only while Spelunk and its agent are.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11.5"
|
||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save your Spelunk API key (Admin role) under Settings → Security and choose it here, or keep it as a credential's password; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -747,6 +839,84 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
============ PORT FORWARDS ============
|
||||
Staged on VaultViewModel.EditorPortForwards the way QUICK ACCESS stages its pins, and saved
|
||||
with the host. Each row is drawn in OpenSSH's own notation, because that is what anybody who has
|
||||
used one reads fastest; the add row spells out which end the listening port is on instead,
|
||||
because -L and -R are the two flags people get backwards.
|
||||
|
||||
No bind-address field, and that is not an omission: every listener binds loopback. A forward is
|
||||
a field every member of a shared keychain can fill in, and one bound to 0.0.0.0 would hand the
|
||||
remote's network to anybody who can reach this machine. See SshPortForwardSpec.
|
||||
-->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="sectionlabel" Text="PORT FORWARDS" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding EditorPortForwards}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="dom:PortForwardRule">
|
||||
<Border Height="33" CornerRadius="9" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="8,0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Notation}" FontSize="12.5"
|
||||
Margin="8,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Notation}" />
|
||||
<Button Grid.Column="2" Classes="flat paneicon" Content="✕" Width="22" Height="22"
|
||||
FontSize="11"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).RemoveEditorForwardCommand}"
|
||||
CommandParameter="{Binding}" ToolTip.Tip="Removes this forward" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<ComboBox SelectedIndex="{Binding EditorNewForwardKind}" HorizontalAlignment="Stretch"
|
||||
Height="36">
|
||||
<ComboBoxItem Content="Local -L" />
|
||||
<ComboBoxItem Content="Remote -R" />
|
||||
<ComboBoxItem Content="Dynamic -D (SOCKS)" />
|
||||
</ComboBox>
|
||||
|
||||
<!-- Which end each port is on, for the kind chosen: the thing -L and -R get backwards. -->
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="{Binding EditorNewForwardKindHint}" />
|
||||
|
||||
<Grid ColumnDefinitions="104,6,*,6,Auto">
|
||||
<TextBox Grid.Column="0" FontFamily="{StaticResource MonoFont}"
|
||||
Text="{Binding EditorNewForwardBindPort}"
|
||||
PlaceholderText="{Binding EditorNewForwardBindHint}" Height="36"
|
||||
ToolTip.Tip="{Binding EditorNewForwardBindHint}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorForwardCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBox Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
||||
Text="{Binding EditorNewForwardTarget}"
|
||||
IsVisible="{Binding EditorNewForwardHasTarget}"
|
||||
PlaceholderText="host:port" Height="36">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorForwardCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="4" Classes="ghost" Content="+" Width="36" Height="36" FontSize="15"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding AddEditorForwardCommand}" ToolTip.Tip="Adds this forward" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="Forwards start with every terminal opened to this host and stop when it closes. Everything listens on localhost only. A change here applies from the next connection." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ THE GROUP EDITOR ============ -->
|
||||
|
||||
@@ -45,7 +45,11 @@
|
||||
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
|
||||
variant of a surface; the pre-multiplied ones there are accent washes.
|
||||
-->
|
||||
<Border Background="#CC05050A">
|
||||
<!--
|
||||
Tab cycles inside the card rather than walking the page under the wash, which it used to: a question
|
||||
that cannot be answered by clicking away should not be answerable by tabbing away either.
|
||||
-->
|
||||
<Border Background="#CC05050A" KeyboardNavigation.TabNavigation="Cycle">
|
||||
<Panel>
|
||||
|
||||
<!-- ============ UNKNOWN HOST KEY ============ -->
|
||||
@@ -83,7 +87,7 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
@@ -119,7 +123,7 @@
|
||||
is not an answer, because this half of the control has none to give. Compare the accent button
|
||||
above, which connects.
|
||||
-->
|
||||
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
||||
<Button x:Name="CloseButton" Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
|
||||
internal sealed partial class HostKeyCard : UserControl
|
||||
{
|
||||
public HostKeyCard() => InitializeComponent();
|
||||
|
||||
/// <summary>The answer the keyboard is given when the card appears: CANCEL, or CLOSE for a changed key.</summary>
|
||||
/// <remarks>
|
||||
/// Never TRUST AND CONNECT, for the reason <see cref="SafeAnswerFocus"/> gives about every confirmation
|
||||
/// and with more force here: an Enter meant for whatever was focused before the card appeared must not
|
||||
/// be what pins a key nobody has compared. The window moves the keyboard rather than the card, because
|
||||
/// this one is raised over a terminal that may be holding it natively — see
|
||||
/// <c>MainWindow.OnHostKeyDecision</c>.
|
||||
/// </remarks>
|
||||
internal Control SafeAnswer => CancelButton.IsEffectivelyVisible ? CancelButton : CloseButton;
|
||||
}
|
||||
@@ -230,9 +230,13 @@
|
||||
|
||||
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
|
||||
who has found this box should know about the one that also connects on Enter.
|
||||
|
||||
One way, and passed back by the code-behind once the typing pauses: every change to the filter
|
||||
rebuilds the board's sections and their cards, and a name typed at speed used to do that once a
|
||||
letter. The view model still filters the moment it is told. See HostsScreen.OnFilterTyped.
|
||||
-->
|
||||
<Border Grid.Row="1" Margin="26,0,26,16">
|
||||
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10"
|
||||
<TextBox x:Name="HostFilter" Text="{Binding HostFilter, Mode=OneWay}" Height="40" CornerRadius="10"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
|
||||
</Border>
|
||||
@@ -457,11 +461,12 @@
|
||||
something that refuses it, leaving the window with nothing focused and the keystrokes going
|
||||
nowhere.
|
||||
|
||||
◆ EVERY GROUP, ONE AFTER ANOTHER, "No group" FIRST. Binds HostSections rather than VisibleHosts:
|
||||
the old grid held one level of a tree at a time, and this holds every group's cards at once, the
|
||||
way SidebarRows always has for the phone. A keychain nobody has filed anything in draws one
|
||||
section with no heading at all — see HostSectionViewModel.HasHeader — which is what makes the
|
||||
feature invisible until it is used.
|
||||
◆ EVERY VAULT, AND INSIDE EACH ONE EVERY GROUP, "No group" FIRST. Binds HostSections rather
|
||||
than VisibleHosts: the old grid held one level of a tree at a time, and this holds every group's
|
||||
cards at once, the way SidebarRows always has for the phone. A keychain nobody has filed
|
||||
anything in draws one section with no heading at all — see HostSectionViewModel.HasHeader —
|
||||
which is what makes the feature invisible until it is used. A session holding one vault draws no
|
||||
vault headings either, for the same reason and by the same rule.
|
||||
-->
|
||||
<ItemsControl x:Name="Board" Focusable="True" ItemsSource="{Binding HostSections}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -472,6 +477,27 @@
|
||||
<DataTemplate x:DataType="vm:HostSectionViewModel">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<!--
|
||||
============ THE VAULT HEADING ============
|
||||
◆ THE BOARD'S OUTER LEVEL. A vault holds groups and a group holds hosts, and until this
|
||||
row existed the board drew only the inner two — every group of every vault in one run,
|
||||
told apart by a small grey vault name beside each heading. Two vaults each holding a
|
||||
"production" were two headings with the same word on them.
|
||||
|
||||
Drawn on the first section of each vault and nowhere else — see
|
||||
HostSectionViewModel.VaultHeading — and on no section at all where the board has one
|
||||
vault, which is what keeps a single-vault keychain looking exactly as it did. The rule
|
||||
to its right is the whole of the emphasis: this level is a divider with a name on it,
|
||||
not a heading competing with the group headings under it.
|
||||
-->
|
||||
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding StartsAVault}" Margin="0,2,0,2">
|
||||
<TextBlock Grid.Column="0" Text="{Binding VaultHeading}" FontSize="11.5"
|
||||
FontWeight="Bold" Foreground="{StaticResource Text}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Grid.Column="1" Height="1" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||
Background="{StaticResource BorderSubtle}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
============ THE SECTION HEADING ============
|
||||
"{label} · {count}", the vault name beside it when more than one vault is in play, a
|
||||
@@ -569,6 +595,18 @@
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).OpenHostPaneCommand}" />
|
||||
<MenuItem Header="Edit…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).EditSelectedHostCommand}" />
|
||||
|
||||
<!--
|
||||
◆ THE SAME ENTRY THE DRAWER'S ⋯ MENU HAS, on the board where the host is. Moving
|
||||
one machine used to mean opening its pane first and then finding the menu inside
|
||||
it, while moving six was a right click away — the one-host case was the harder of
|
||||
the two, which is backwards. The command raises the drawer itself (see
|
||||
VaultViewModel.MoveHost); the picker it opens lives in that footer and nowhere
|
||||
else, and an entry that armed a panel nobody could see would be a menu item that
|
||||
did nothing.
|
||||
-->
|
||||
<MenuItem Header="Move to another vault…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).MoveHostCommand}" />
|
||||
<Separator IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" />
|
||||
<MenuItem Header="Delete…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).DeleteHostCommand}" />
|
||||
@@ -600,16 +638,26 @@
|
||||
The full address is on the tooltip and nowhere else on the card — the design's own
|
||||
rule, kept: a card read while scanning a section is read for its name and whether
|
||||
it is live, and an address is what somebody reads once they have found the machine,
|
||||
which the tooltip and the drawer both still say.
|
||||
which the tooltip and the drawer both still say. The name is on the tooltip too,
|
||||
and that is not duplication of the line six pixels above it: 214 pixels of card
|
||||
trims it, and the tooltip is where the untrimmed one lives. See
|
||||
HostRowViewModel.Tooltip.
|
||||
|
||||
MinHeight rather than a fixed Height, unlike the mock's own 120: this codebase's
|
||||
Border.tile is deliberately a fixed width and a free height (see App.axaml), so a
|
||||
card wearing four tags grows a second line rather than clipping the fourth one — a
|
||||
silent truncation is exactly the kind of thing the honesty rule in
|
||||
HANDOFF-hosts-v5.md exists to catch, and it costs nothing here to avoid.
|
||||
|
||||
◆ 104 RATHER THAN THE MOCK'S 120, and the floor is the only thing that moved. What
|
||||
a card with no tags actually needs is about 87 — 20 of padding, 43 of name and
|
||||
address, 24 for the row along the bottom — so 120 was thirty pixels of air below
|
||||
every card on a board that is scrolled. It is still a floor with room in it: the
|
||||
number is deliberately above what the content measures, so the common card is a
|
||||
uniform height rather than each one shrink-wrapping its own last line.
|
||||
-->
|
||||
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="120"
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="104"
|
||||
ToolTip.Tip="{Binding Tooltip}">
|
||||
<Panel>
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
@@ -639,7 +687,7 @@
|
||||
TextTrimming never fires and the name would run on past the card's own
|
||||
border instead of trimming. See the longer note this carried in v4.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
|
||||
FontWeight="Bold" Foreground="{StaticResource Text}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
@@ -664,17 +712,27 @@
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
Width="9" Height="9" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="2" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
|
||||
<!--
|
||||
"not synced" and its kin sit on the address line. Beside the name, where it
|
||||
used to be, it took seventy of the card's pixels from the one thing a card
|
||||
is read for: "bastion" drew as "bast…", and k8s-worker-a and k8s-worker-b
|
||||
both drew as "k8s…". The address can give the room up — it trims, and the
|
||||
tooltip and the drawer both carry it whole.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}" FontSize="12.5"
|
||||
Foreground="{StaticResource TextGhost}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="12.5"
|
||||
Foreground="{StaticResource TextGhost}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!--
|
||||
Which vault this host is in, and only when there is more than one to be
|
||||
in. It decides who else can see the host and where an edit goes back to,
|
||||
@@ -690,8 +748,13 @@
|
||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
|
||||
Margin="0,8,0,0">
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4"
|
||||
VerticalAlignment="Bottom">
|
||||
<!--
|
||||
A DockPanel rather than a horizontal StackPanel. A horizontal StackPanel
|
||||
measures its children with infinite width, so the tags' WrapPanel below
|
||||
never wrapped and ran past the card's edge; the last child of a DockPanel
|
||||
gets the width that is actually left, and wraps in it.
|
||||
-->
|
||||
<DockPanel Grid.Column="0" VerticalAlignment="Bottom">
|
||||
|
||||
<!--
|
||||
The pin badge: how many folders are pinned on this host, reading
|
||||
@@ -699,8 +762,8 @@
|
||||
property's own remarks. Only when there is at least one; a badge reading
|
||||
zero would be a fact nobody asked for.
|
||||
-->
|
||||
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
|
||||
IsVisible="{Binding HasPins}">
|
||||
<Border DockPanel.Dock="Left" Classes="chip" Height="19" CornerRadius="5"
|
||||
Padding="6,2" Margin="0,0,4,4" IsVisible="{Binding HasPins}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="3">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
||||
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
||||
@@ -723,12 +786,13 @@
|
||||
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
|
||||
Margin="0,0,4,4">
|
||||
<TextBlock Text="{Binding}" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
<!--
|
||||
HostRowViewModel.LastConnectedText is filled from the connection log on
|
||||
|
||||
@@ -106,6 +106,48 @@ internal sealed partial class HostsScreen : UserControl
|
||||
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
||||
|
||||
WireTheBand();
|
||||
|
||||
HostFilter.TextChanged += OnFilterTyped;
|
||||
}
|
||||
|
||||
/// <summary>How long the find box waits for the typing to pause before the board is rebuilt.</summary>
|
||||
private static readonly TimeSpan FilterPause = TimeSpan.FromMilliseconds(150);
|
||||
|
||||
/// <summary>The wait itself, replaceable so a test can say when a pause is over rather than sleep through one.</summary>
|
||||
internal Func<TimeSpan, Task> Pause { get; set; } = static span => Task.Delay(span);
|
||||
|
||||
/// <summary>How many times the find box has changed, so a wait can tell it is no longer the latest.</summary>
|
||||
private int typed;
|
||||
|
||||
/// <summary>Hands the find box's text to the vault once the typing pauses.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every change to the filter rebuilds the board's sections and every card in them, and a name typed at
|
||||
/// speed used to do that once a letter. Now it is done once, with the whole of what was typed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A wait rather than a binding delay, and on the dispatcher's own queue: the box is bound one way, so a
|
||||
/// filter the vault sets itself — cleared, or restored — still lands in the box at once, and the
|
||||
/// change it makes to the box comes back here as the same text, which changes nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnFilterTyped(object? sender, TextChangedEventArgs e) =>
|
||||
_ = PassTheFilterOnAsync(HostFilter.Text ?? string.Empty, ++typed);
|
||||
|
||||
private async Task PassTheFilterOnAsync(string text, int keystroke)
|
||||
{
|
||||
await Pause(FilterPause).ConfigureAwait(true);
|
||||
|
||||
// A later letter has its own wait, and it is the one that speaks.
|
||||
if (keystroke != typed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Vault is { } vault && !string.Equals(vault.HostFilter, text, StringComparison.Ordinal))
|
||||
{
|
||||
vault.HostFilter = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
|
||||
@@ -122,6 +164,13 @@ internal sealed partial class HostsScreen : UserControl
|
||||
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
|
||||
Board.KeyDown += OnGridKey;
|
||||
|
||||
// The arrows' half of the selection, which the pointer's half writes by hand in OnPointerPressed. See
|
||||
// OnCardSelectionChanged for why this is not simply a two-way binding.
|
||||
Board.AddHandler(SelectingItemsControl.SelectionChangedEvent, OnCardSelectionChanged);
|
||||
|
||||
// Tunnelled, because a section's list answers Enter itself and nothing bubbles past it.
|
||||
Board.AddHandler(KeyDownEvent, OnCardEnter, RoutingStrategies.Tunnel);
|
||||
|
||||
// ◆ The band is the scroller's rather than any one section list's, because the space it is dragged
|
||||
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
|
||||
// between sections and below the last one belong to the stack around them. Tunnelled for the reason
|
||||
@@ -263,7 +312,9 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
if (control)
|
||||
{
|
||||
vault.ToggleHostChoiceCommand.Execute(row);
|
||||
// Extend rather than toggle: the set this starts has the lit card in it, which is the half a
|
||||
// plain toggle left out. See VaultViewModel.ExtendHostChoice.
|
||||
vault.ExtendHostChoiceCommand.Execute(row);
|
||||
anchor = row;
|
||||
|
||||
Board.Focus();
|
||||
@@ -313,6 +364,16 @@ internal sealed partial class HostsScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
// An arrow with the board itself focused — which is where this screen puts the keyboard, see
|
||||
// KeyboardTarget — goes into the cards: onto the selected one, or the first. Otherwise no arrow ever
|
||||
// reaches a section's list, since none of them has the keyboard until a card has been clicked.
|
||||
if (e.Key is Key.Up or Key.Down or Key.Left or Key.Right && ReferenceEquals(e.Source, Board))
|
||||
{
|
||||
e.Handled = EnterTheCards(vault);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
|
||||
{
|
||||
var order = vault.HostBoardOrder.ToList();
|
||||
@@ -345,6 +406,80 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>Enter on a card is the double-press, for somebody who got there with the arrows.</summary>
|
||||
/// <remarks>
|
||||
/// Only on a card, for the reason <see cref="OnHostActivated"/> gives: Enter with the board itself focused
|
||||
/// would connect to whatever was selected before, which is not something anyone is looking at. And not
|
||||
/// while cards are ticked, where the selection is not what the set is about.
|
||||
/// </remarks>
|
||||
private void OnCardEnter(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is not Key.Enter
|
||||
|| e.KeyModifiers != KeyModifiers.None
|
||||
|| Vault is not { IsChoosingHosts: false } vault
|
||||
|| RowUnder(e.Source) is not HostRowViewModel row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vault.SelectedHost = row;
|
||||
|
||||
_ = vault.ConnectCommand.ExecuteAsync(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>Writes a selection the arrows made in one section's list back to the vault.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The cards' <c>SelectedItem</c> is one-way, for the reason the markup gives: several lists rebuilt
|
||||
/// independently would each null the vault's selection on every filter keystroke and every sync pass. So
|
||||
/// the pointer writes it by hand, and until this nothing wrote it for the arrows — a card lit by the
|
||||
/// keyboard was not the one the drawer's CONNECT and DELETE then acted on, which is the same mistake the
|
||||
/// right-click handler exists to prevent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ Only from the list that has the keyboard, and only for a card. A rebuild's reset arrives as a change
|
||||
/// to nothing, and the binding's own refills of the lists nobody is in arrive without the keyboard — both
|
||||
/// are exactly the writes the one-way binding keeps out, and they stay out. A click arrives here too, after
|
||||
/// <see cref="OnPointerPressed"/> has already written the same card, and changes nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnCardSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (Vault is not { } vault
|
||||
|| e.Source is not ListBox { IsKeyboardFocusWithin: true, SelectedItem: HostRowViewModel row }
|
||||
|| ReferenceEquals(row, vault.SelectedHost))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
anchor = row;
|
||||
vault.SelectedHost = row;
|
||||
}
|
||||
|
||||
/// <summary>Hands the keyboard from the board to a card: the selected one if it is drawn, else the first.</summary>
|
||||
private bool EnterTheCards(VaultViewModel vault)
|
||||
{
|
||||
var cards = Board.GetVisualDescendants().OfType<ListBoxItem>().Where(card => card.IsEffectivelyVisible).ToList();
|
||||
|
||||
var card = cards.Find(card => ReferenceEquals(card.DataContext, vault.SelectedHost)) ?? cards.FirstOrDefault();
|
||||
|
||||
if (card?.DataContext is not HostRowViewModel row)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
vault.SelectedHost = row;
|
||||
anchor = row;
|
||||
|
||||
return card.Focus(NavigationMethod.Directional);
|
||||
}
|
||||
|
||||
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
EndBand();
|
||||
|
||||
@@ -131,8 +131,20 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
|
||||
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
|
||||
<ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.Template>
|
||||
<ControlTemplate>
|
||||
<ScrollViewer>
|
||||
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
|
||||
</ScrollViewer>
|
||||
</ControlTemplate>
|
||||
</ItemsControl.Template>
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
||||
<Border Classes="importrow" CornerRadius="8">
|
||||
@@ -161,8 +173,7 @@
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</ItemsControl>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -256,7 +256,8 @@
|
||||
</Border>
|
||||
|
||||
<!-- ============ The table ============ -->
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}">
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}"
|
||||
Grid.IsSharedSizeScope="True">
|
||||
|
||||
<Border Grid.Row="0" Padding="16,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
@@ -275,12 +276,24 @@
|
||||
item this application stores carries a timestamp; see the file-level remark above and
|
||||
docs/design-import-gaps.md.
|
||||
-->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="2,2.2*,1*,1*,1.4*" Margin="0,10,16,6"
|
||||
<!--
|
||||
VAULT is sized to what is in it, and shared between this heading and every row so the two agree:
|
||||
with one vault no row names one, and a fixed column was a heading over a stripe of nothing.
|
||||
-->
|
||||
<Grid Grid.Row="1" Margin="0,10,16,6"
|
||||
IsVisible="{Binding HasVaultItems}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="2" />
|
||||
<ColumnDefinition Width="3*" />
|
||||
<ColumnDefinition Width="1.1*" />
|
||||
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
|
||||
<ColumnDefinition Width="1.2*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
|
||||
Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
|
||||
Margin="0,0,16,0" IsVisible="{Binding ShowsVaultColumn}" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
@@ -290,18 +303,31 @@
|
||||
Margin="8,0,8,10">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||||
<Grid ColumnDefinitions="2,2.2*,1*,1*,1.4*" Height="40" Margin="6,0,10,0">
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" Margin="10,0,8,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||
<Grid Height="40" Margin="6,0,10,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="2" />
|
||||
<ColumnDefinition Width="3*" />
|
||||
<ColumnDefinition Width="1.1*" />
|
||||
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
|
||||
<ColumnDefinition Width="1.2*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<!--
|
||||
A Grid rather than a horizontal StackPanel: the StackPanel measured the name with infinite
|
||||
width, so TextTrimming never fired and a long name printed over the TYPE and USED BY
|
||||
columns beside it.
|
||||
-->
|
||||
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="10,0,8,0" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="12.5" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Name}" />
|
||||
</Grid>
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left"
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left" Margin="0,0,16,0"
|
||||
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
|
||||
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
|
||||
</Border>
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
a sidebar naming no host would be furniture with nothing to say. Collapsing frees the
|
||||
"Auto" column it sits in, so the pane column takes the width back.
|
||||
-->
|
||||
<views:SessionSidebar Grid.Column="1" ShowsSnips="True"
|
||||
<views:SessionSidebar Grid.Column="1" ShowsSnips="True" ShowsForwards="True"
|
||||
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -383,7 +383,12 @@
|
||||
not replayed. See MainWindow.axaml.cs.
|
||||
-->
|
||||
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
||||
PlaceholderText="https://dodossh.example" />
|
||||
PlaceholderText="https://dodossh.example">
|
||||
<!-- Enter signs in, as it unlocks on the unlock card. -->
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SignInCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
@@ -395,8 +400,14 @@
|
||||
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
||||
<TextBox x:Name="NewPassphraseBox" Text="{Binding Passphrase}" PlaceholderText="passphrase"
|
||||
PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•">
|
||||
<!-- On the second box only: Enter in the first moves nowhere useful, Enter here is "done". -->
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
|
||||
@@ -33,6 +33,11 @@ internal sealed partial class MainWindow : Window
|
||||
|
||||
private bool wasUnlocked;
|
||||
|
||||
/// <summary>Which closed-vault card the keyboard was last sent to, so it is sent once per card.</summary>
|
||||
private ShellState? closedState;
|
||||
|
||||
private bool wasAskingAboutHostKey;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -184,6 +189,7 @@ internal sealed partial class MainWindow : Window
|
||||
{
|
||||
ShellState.Locked => UnlockPane.PassphraseBox,
|
||||
ShellState.NeedsServer => ServerUrlBox,
|
||||
ShellState.NeedsEnrollment => NewPassphraseBox,
|
||||
_ => this,
|
||||
};
|
||||
|
||||
@@ -226,33 +232,96 @@ internal sealed partial class MainWindow : Window
|
||||
size.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
// A host-key decision before the palette, because it is drawn over the palette: Escape is CANCEL or
|
||||
// CLOSE, both of which refuse — never the answer that connects. See HostKeyCard.SafeAnswer.
|
||||
else if (e.Key == Key.Escape && viewModel.IsHostKeyDecisionShowing)
|
||||
{
|
||||
viewModel.Vault?.RejectHostKeyCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (viewModel.IsSearching)
|
||||
{
|
||||
Palette.HandleKey(e);
|
||||
}
|
||||
// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape
|
||||
// already follows one branch up. Checked after the palette rather than before it: the two states
|
||||
// are mutually exclusive in practice — opening the palette does not enter settings mode and entering
|
||||
// settings does not open the palette — but an Escape while both were somehow true should close the
|
||||
// thing drawn on top, which is the palette.
|
||||
//
|
||||
// v5c: with the importer up, Escape closes only that — the same "closest thing first" rule, and the
|
||||
// same one the titlebar's own back button follows by showing "Back to preferences" rather than
|
||||
// "Back to application" while IsImportOpen is true.
|
||||
else if (e.Key == Key.Escape && viewModel.IsImportOpen)
|
||||
else if (e.Key == Key.Escape && CloseTheNearestThing(viewModel))
|
||||
{
|
||||
viewModel.CloseImportCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
|
||||
{
|
||||
viewModel.LeaveSettingsCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
/// <summary>What Escape closes when the palette is not up: the thing drawn nearest the user.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One rule throughout — closest thing first — so the order of these arms is the order things are drawn
|
||||
/// over one another. A confirmation before anything, because it is a question in front of whatever page
|
||||
/// it is on, including a settings page: Escape on the sign-out question cancels it and stays in settings.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape already
|
||||
/// follows in the caller. Checked after the palette rather than before it: the two states are mutually
|
||||
/// exclusive in practice — opening the palette does not enter settings mode and entering settings does
|
||||
/// not open the palette — but an Escape while both were somehow true should close the thing drawn on
|
||||
/// top, which is the palette.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// v5c: with the importer up, Escape closes only that — the same rule, and the same one the titlebar's own
|
||||
/// back button follows by showing "Back to preferences" rather than "Back to application" while
|
||||
/// IsImportOpen is true. The members dialog on Settings → Vaults likewise: it is drawn over the page, so
|
||||
/// it goes first and settings stays.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Last, because it is the furthest thing back: the hosts drawer, which Escape puts away exactly as its
|
||||
/// own arrow does — an open editor abandoned, the selection kept. See VaultViewModel.CloseDrawer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static bool CloseTheNearestThing(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (AnswerNoToTheQuestionShowing(viewModel))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var close = viewModel switch
|
||||
{
|
||||
{ IsImportOpen: true } => viewModel.CloseImportCommand,
|
||||
{ IsSettingsMode: true, Vaults.IsMembersPanelOpen: true } => viewModel.Vaults.CloseMembersPanelCommand,
|
||||
{ IsSettingsMode: true } => viewModel.LeaveSettingsCommand,
|
||||
{ IsUnlocked: true, IsTerminalShowing: false, Screen: ShellScreen.Hosts, Vault: { IsDrawerOpen: true } vault }
|
||||
=> vault.CloseDrawerCommand,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
close?.Execute(null);
|
||||
|
||||
return close is not null;
|
||||
}
|
||||
|
||||
/// <summary>Escape on a confirmation drawn in place: the sign-out one, or a deletion's.</summary>
|
||||
/// <remarks>
|
||||
/// Both answers it gives are CANCEL, which is the only thing a key with no label is allowed to mean on a
|
||||
/// question about destroying something. A deletion question left armed on a screen somebody navigated away
|
||||
/// from is cancelled by it too, which is harmless — it is the same answer the question would get if it
|
||||
/// were on screen — and cheaper than deciding which of the six places that card is drawn is showing.
|
||||
/// </remarks>
|
||||
private static bool AnswerNoToTheQuestionShowing(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (viewModel.IsConfirmingSignOut)
|
||||
{
|
||||
viewModel.CancelSignOutCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (viewModel is { IsUnlocked: true, IsTerminalShowing: false, Vault: { IsConfirmingDeletion: true } vault })
|
||||
{
|
||||
vault.CancelDeleteCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The text-size chords, when the terminal is not the thing hearing them.
|
||||
/// </summary>
|
||||
@@ -300,6 +369,15 @@ internal sealed partial class MainWindow : Window
|
||||
Terminal.Source = viewModel.TerminalPageUrl;
|
||||
|
||||
wasUnlocked = viewModel.IsUnlocked;
|
||||
closedState = null;
|
||||
wasAskingAboutHostKey = viewModel.IsHostKeyDecisionShowing;
|
||||
|
||||
// The shell may have reached its card before this window was given it, and then no change would
|
||||
// ever say so.
|
||||
if (!viewModel.IsUnlocked)
|
||||
{
|
||||
FocusClosedVaultCard(viewModel);
|
||||
}
|
||||
|
||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||
@@ -358,6 +436,10 @@ internal sealed partial class MainWindow : Window
|
||||
OnSelectedTabChanged(viewModel);
|
||||
break;
|
||||
|
||||
case nameof(MainWindowViewModel.IsHostKeyDecisionShowing):
|
||||
OnHostKeyDecision(viewModel);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -373,10 +455,77 @@ internal sealed partial class MainWindow : Window
|
||||
{
|
||||
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
||||
}
|
||||
else if (!unlocked && viewModel.State != closedState)
|
||||
{
|
||||
FocusClosedVaultCard(viewModel);
|
||||
}
|
||||
|
||||
closedState = unlocked ? null : viewModel.State;
|
||||
wasUnlocked = unlocked;
|
||||
}
|
||||
|
||||
/// <summary>Hands the keyboard to the box on whichever card a closed vault is showing.</summary>
|
||||
/// <remarks>
|
||||
/// For arriving at a card other than by locking — launch landing on the unlock card, a sign-in finishing
|
||||
/// on the enrolment one — which used to leave the passphrase somebody started typing going nowhere. Once
|
||||
/// per card, which is what keeps it from moving the keyboard while somebody is mid-sign-in: IsUnlocked is
|
||||
/// raised for every state change, and the state does not change while they type. Posted, because the card
|
||||
/// is revealed by a binding the same change has not reached yet.
|
||||
/// </remarks>
|
||||
private void FocusClosedVaultCard(MainWindowViewModel viewModel)
|
||||
{
|
||||
closedState = viewModel.State;
|
||||
|
||||
var home = ClosedVaultKeyboardHome;
|
||||
|
||||
Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
/// <summary>Gives the host-key card the keyboard when it appears, and gives it back when it goes.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// It used to appear with the keyboard wherever it had been — often inside the terminal it covers, whose
|
||||
/// native window goes on holding it after the WebView is collapsed, so the card took no keys at all and
|
||||
/// Tab walked the page under it. Hence <see cref="ReleaseKeyboardTo"/> rather than <c>Focus()</c>, and
|
||||
/// posted, because the binding that reveals the card has not run yet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Latched, because the flag is re-raised with every change to the terminal's state and the keyboard must
|
||||
/// move once per question, not once per raise. On the way out the keyboard goes where the window would
|
||||
/// put it for whatever is now showing; a connection that went ahead moves it again when its terminal opens.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnHostKeyDecision(MainWindowViewModel viewModel)
|
||||
{
|
||||
var asking = viewModel.IsHostKeyDecisionShowing;
|
||||
|
||||
if (asking == wasAskingAboutHostKey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
wasAskingAboutHostKey = asking;
|
||||
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (shell != viewModel || viewModel.IsHostKeyDecisionShowing != asking)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (asking)
|
||||
{
|
||||
ReleaseKeyboardTo(HostKeyPane.SafeAnswer);
|
||||
}
|
||||
else
|
||||
{
|
||||
OnShowingSomethingElse(viewModel);
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
||||
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
||||
|
||||
@@ -252,13 +252,23 @@
|
||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||
|
||||
<!--
|
||||
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
|
||||
see MainWindowViewModel.EnterSettings and SettingsView.axaml. Settings opens on General, the
|
||||
mode's own default landing page; Vaults and Preferences open directly on the page they name,
|
||||
which is also what anything that used to navigate to ShellScreen.Vaults or
|
||||
ShellScreen.Preferences now does — see ShowScreen. Three handlers rather than the two rows
|
||||
sharing one before this wave: the mock's Settings area was a family of screens that did not
|
||||
exist yet, and now that it does, "Settings" and "Preferences" are no longer the same click.
|
||||
◆ ONE ROW, WHERE v5c PUT THREE.
|
||||
|
||||
Settings, Vaults and Preferences all opened the same full-window settings mode — see
|
||||
MainWindowViewModel.EnterSettings and SettingsView.axaml — differing only in which of its
|
||||
seven pages they landed on. Two of the three were therefore deep links into a place the third
|
||||
one opens, and SettingsNav is already on screen the whole time listing every page including
|
||||
those two, so the shortcut saved one click and cost a menu that read as though it offered
|
||||
three destinations.
|
||||
|
||||
It was three rows because settings mode had only just been built out of what used to be two
|
||||
bare ShellScreens: "Settings" and "Preferences" were the same click before that wave, and
|
||||
splitting them was the fix for a real thing. Folding the other two back in is not a return to
|
||||
that state — Vaults and Preferences are pages now, reached from the rail inside settings, and
|
||||
what this menu is for is getting there at all.
|
||||
|
||||
What still lands on a page rather than on General is Logout below, and it has to: its
|
||||
confirmation card is drawn inline on the Account page and nowhere else.
|
||||
-->
|
||||
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
@@ -268,29 +278,6 @@
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
◆ THE SAME TREATMENT AS SETTINGS ABOVE AND LOGOUT BELOW, which these two did not have: their
|
||||
label was TextGhost where the other two rows' was Text, so a menu of five equally live
|
||||
destinations drew two of them in the colour this window uses for something switched off. The
|
||||
icons stay one step quieter than the words — the idiom the nav rail's own rows already follow
|
||||
— but "quieter than the word beside it" and "dimmed" are not the same statement.
|
||||
-->
|
||||
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||
|
||||
<!--
|
||||
|
||||
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user