Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad1a129159 | ||
|
|
e240c0cf26 | ||
|
|
96dd176121 | ||
|
|
367cebae9b | ||
|
|
c21de4ade4 | ||
|
|
858e36279d | ||
|
|
55e5de0ce4 | ||
|
|
8cb1457ca9 | ||
|
|
656a33b4b1 | ||
|
|
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 |
No files matched your search
@@ -10,7 +10,7 @@
|
|||||||
"rollForward": false
|
"rollForward": false
|
||||||
},
|
},
|
||||||
"vpk": {
|
"vpk": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.161",
|
||||||
"commands": [
|
"commands": [
|
||||||
"vpk"
|
"vpk"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1221,27 +1221,55 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true
|
curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
# vpk rather than curl, unlike the android job above, and the difference is what is being
|
# curl, like the android job above, and no longer `vpk upload gitea`. vpk's Gitea client is a
|
||||||
# uploaded. An APK is one file the client is told about by a manifest this repository writes; a
|
# generated SDK that cannot read the forge's answer to "create a release" since its upgrade to
|
||||||
# Velopack release is a set of files plus an index whose format is Velopack's own. Writing that
|
# 28.0.0: the release is created and the client gets null back, so both 1.2.0 and 1.2.161 threw a
|
||||||
# index by hand would be reimplementing the tool that is already here.
|
# NullReferenceException before uploading anything, leaving an empty draft behind on every push.
|
||||||
|
# Nothing here writes Velopack's index by hand: `vpk pack` already wrote releases.win-nightly.json
|
||||||
|
# beside the packages, and with no earlier release downloaded into that directory it is exactly
|
||||||
|
# the index `vpk upload` would have generated from it.
|
||||||
#
|
#
|
||||||
# --pre is load-bearing twice over. It keeps this out of the release channel, which refuses
|
# A draft first, published only once everything is on it, which is the order vpk used too: the
|
||||||
|
# client reads the index and then fetches what it names, and a published release missing a
|
||||||
|
# package is a few seconds in which every desktop is told to download something that is not
|
||||||
|
# there. The index goes up last for the same reason.
|
||||||
|
#
|
||||||
|
# prerelease is load-bearing twice over. It keeps this out of the release channel, which refuses
|
||||||
# prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
|
# prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
|
||||||
# channel reads: a desktop nightly published as a stable release would become the newest release
|
# channel reads: a desktop nightly published as a stable release would become the newest release
|
||||||
# in this repository and every phone on the release channel would start failing its update check
|
# in this repository and every phone on the release channel would start failing its update check
|
||||||
# against a release carrying no android manifest.
|
# against a release carrying no android manifest.
|
||||||
dotnet vpk upload gitea \
|
releases="$RUNNER_TEMP/nightly-releases"
|
||||||
--skip-updates \
|
|
||||||
--repoUrl "$FORGE/$REPO" \
|
created="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||||
--token "$TOKEN" \
|
-d "$(printf '{"tag_name":"%s","target_commitish":"%s","name":"Nightly desktop %s","draft":true,"prerelease":true,"body":"Built from %s by CI. Installs beside the release build, never over it."}' \
|
||||||
--outputDir "$RUNNER_TEMP/nightly-releases" \
|
"$TAG" "$GITHUB_SHA" "$VERSION" "$GITHUB_SHA")" \
|
||||||
--channel win-nightly \
|
"$api/releases")"
|
||||||
--tag "$TAG" \
|
|
||||||
--releaseName "Nightly desktop $VERSION" \
|
draft="$(printf '%s' "$created" | release_id)"
|
||||||
--targetCommitish "$GITHUB_SHA" \
|
if [ -z "$draft" ]; then
|
||||||
--pre \
|
echo "Gitea accepted the release call and returned no id:" >&2
|
||||||
--publish
|
printf '%s\n' "$created" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for file in "$releases"/*-full.nupkg "$releases"/*-Portable.zip "$releases"/*-Setup.exe \
|
||||||
|
"$releases"/releases.win-nightly.json; do
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
echo "vpk pack did not leave $file behind:" >&2
|
||||||
|
ls -la "$releases" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
name="$(basename "$file")"
|
||||||
|
echo "Uploading $name ($(wc -c < "$file") bytes)"
|
||||||
|
curl -fsS -X POST -H "$auth" \
|
||||||
|
-F "attachment=@$file" \
|
||||||
|
"$api/releases/$draft/assets?name=$name" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
curl -fsS -X PATCH -H "$auth" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"draft":false}' \
|
||||||
|
"$api/releases/$draft" >/dev/null
|
||||||
|
|
||||||
# Asked for back rather than assumed, and the android job's history is why: it once created a
|
# Asked for back rather than assumed, and the android job's history is why: it once created a
|
||||||
# release, uploaded nothing, and reported success for every upload it never made. A nightly
|
# release, uploaded nothing, and reported success for every upload it never made. A nightly
|
||||||
@@ -1260,20 +1288,16 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# ◆ THE RPM GOES ONTO THE RELEASE vpk JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
|
# ◆ THE RPM GOES ONTO THE RELEASE JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
|
||||||
#
|
#
|
||||||
# One rolling release carrying both heads, because they are one nightly: the same commit,
|
# One rolling release carrying both heads, because they are one nightly: the same commit,
|
||||||
# the same version, and one page a person looks at to find out what is on main. A second tag
|
# the same version, and one page a person looks at to find out what is on main. A second tag
|
||||||
# would double the delete-and-recreate dance this step already does, and would let the two
|
# would double the delete-and-recreate dance this step already does, and would let the two
|
||||||
# drift apart on a run where one of them failed.
|
# drift apart on a run where one of them failed.
|
||||||
#
|
#
|
||||||
# It has to happen here, after `vpk upload`, and not in a step before it. vpk deletes and
|
# It has to happen here, after the Windows upload, and not in a step before it. This step
|
||||||
# recreates this release, so an RPM uploaded first would be attached to something that no
|
# deletes and recreates the release, so an RPM uploaded first would be attached to something
|
||||||
# longer exists by the time the job ends — silently, because the delete succeeds.
|
# that no longer exists by the time the job ends — silently, because the delete succeeds.
|
||||||
#
|
|
||||||
# curl rather than vpk, unlike the assets above, for the reason the android job gives about
|
|
||||||
# its APK: this is one file the release simply carries. There is no index to write and no
|
|
||||||
# format that belongs to a tool.
|
|
||||||
release="$(printf '%s' "$published" | release_id)"
|
release="$(printf '%s' "$published" | release_id)"
|
||||||
if [ -z "$release" ]; then
|
if [ -z "$release" ]; then
|
||||||
echo "The release was published and its id could not be read back:" >&2
|
echo "The release was published and its id could not be read back:" >&2
|
||||||
@@ -1326,6 +1350,19 @@ jobs:
|
|||||||
# inspects — it is the thing that gets deployed.
|
# inspects — it is the thing that gets deployed.
|
||||||
needs: [build]
|
needs: [build]
|
||||||
runs-on: [linux]
|
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:
|
steps:
|
||||||
# Duplicated from the build job; see the comment there for why it cannot be factored
|
# 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.
|
# out. Any change here has to be made in all three.
|
||||||
@@ -1451,9 +1488,6 @@ jobs:
|
|||||||
# changes, and where setup-buildx-action starts earning its place.
|
# changes, and where setup-buildx-action starts earning its place.
|
||||||
- name: work out the tags
|
- name: work out the tags
|
||||||
id: tags
|
id: tags
|
||||||
env:
|
|
||||||
REGISTRY: registry-docker.dodotech.cloud
|
|
||||||
IMAGE: dodotech/dodossh-api
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
repo="$REGISTRY/$IMAGE"
|
repo="$REGISTRY/$IMAGE"
|
||||||
@@ -1578,7 +1612,7 @@ jobs:
|
|||||||
echo "username: ${#REGISTRY_USERNAME} characters; password: set"
|
echo "username: ${#REGISTRY_USERNAME} characters; password: set"
|
||||||
|
|
||||||
printf '%s' "$REGISTRY_PASSWORD" \
|
printf '%s' "$REGISTRY_PASSWORD" \
|
||||||
| docker login registry-docker.dodotech.cloud \
|
| docker login "$REGISTRY" \
|
||||||
--username "$REGISTRY_USERNAME" --password-stdin
|
--username "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
|
||||||
- name: push
|
- name: push
|
||||||
@@ -1596,7 +1630,7 @@ jobs:
|
|||||||
# does not leave it behind.
|
# does not leave it behind.
|
||||||
- name: log out
|
- name: log out
|
||||||
if: always() && github.event_name != 'pull_request'
|
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
|
# 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
|
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
constant rather than a setting: an operator who could answer the update check could pin a
|
constant rather than a setting: an operator who could answer the update check could pin a
|
||||||
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
|
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="Velopack" Version="1.2.0" />
|
<PackageVersion Include="Velopack" Version="1.2.161" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
|
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
|
||||||
|
|||||||
@@ -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 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 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
|
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
|
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
|
piece would land in and what the interface shows in its place. Nothing was rendered with invented data to
|
||||||
@@ -426,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
|
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).
|
[`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
|
### Sharing a vault
|
||||||
|
|
||||||
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
|
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
|
||||||
@@ -641,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
|
**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
|
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
|
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
|
shell, a host's files over SFTP, or a bucket. Beside the `+`, for a tab whose host has port forwards, a `⇄`
|
||||||
that menu first if it is open.
|
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
|
Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
|
||||||
screen exactly the width it was designed against.
|
screen exactly the width it was designed against.
|
||||||
@@ -710,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
|
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.
|
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
|
**Port forwarding runs here too, and is edited on the desktop.** A host's forwards start with every
|
||||||
leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so
|
terminal the phone opens to it, as they do on the desktop. The terminal's bar grows a `⇄` for a tab that has
|
||||||
every control on that screen would have had no effect. See
|
any, carrying the number listening in green, or turning red when one could not start; it opens a sheet
|
||||||
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
|
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
|
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
|
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
# Android design audit — 2026-10-02
|
||||||
|
|
||||||
|
Run on the emulator (`dodossh_api36`, Pixel 5 profile, 1080×2340 @ 440 dpi, Android 16). It used a
|
||||||
|
standalone **Debug** APK from `main` at `858e362`, against a local API, Keycloak and a throwaway `sshd`.
|
||||||
|
The walk went from a cleared install through sign-in, enrolment, recovery code, unlock, adding a host,
|
||||||
|
connecting, host-key trust, terminal, and every Settings sub-page.
|
||||||
|
|
||||||
|
Screenshots are in [`android/`](android/), downscaled. The recovery-code screen can't be captured,
|
||||||
|
because `FLAG_SECURE` blocks it, so it was reviewed from its markup and the accessibility tree.
|
||||||
|
|
||||||
|
**How to read the evidence.** "a11y tree" means `uiautomator dump`, which reads what TalkBack and any
|
||||||
|
other accessibility service see. Times and hangs come from a Debug build on an emulator (JIT, no AOT),
|
||||||
|
so treat any performance note as "confirm on Release".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What works well
|
||||||
|
|
||||||
|
- **Connection progress card** ([11](android/11-connecting.png)): five named steps, the current one lit,
|
||||||
|
with *close* and *logs* available while it's in progress.
|
||||||
|
- **Host-key sheet** ([13](android/13-host-key.png)): "UNKNOWN HOST KEY", first contact, the algorithm,
|
||||||
|
the full SHA256 fingerprint, and an instruction to compare. **TRUST AND CONNECT** and **CANCEL** are
|
||||||
|
both full width.
|
||||||
|
- **Recovery-code screen**:
|
||||||
|
- no back and no skip;
|
||||||
|
- **CONTINUE** stays disabled until the tick;
|
||||||
|
- a full-width **COPY CODE**;
|
||||||
|
- capture is blocked.
|
||||||
|
- **Terminal** ([14](android/14-terminal.png)):
|
||||||
|
- resizes above the keyboard;
|
||||||
|
- the two-row accessory bar (esc, tab, ctrl, `| - ~ /`, arrows, A−/A+) stays reachable;
|
||||||
|
- the open-sessions strip and the tab strip are clear.
|
||||||
|
- **Busy states**: enrolment disables its fields and button and explains the wait ("deliberately takes a
|
||||||
|
moment").
|
||||||
|
- **Touch targets** measure 44 dp or more almost everywhere: checkboxes, buttons, rows.
|
||||||
|
- **Back** closes sheets. The soft keyboard's ✓ submits single-field forms (unlock, connect).
|
||||||
|
- **Copy is honest and specific**, e.g. "works with no network — the keychain decrypts on this phone",
|
||||||
|
and the Vaults explanation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical / high
|
||||||
|
|
||||||
|
### H1 · Password fields expose their plaintext to accessibility services
|
||||||
|
|
||||||
|
- **What:** the vault passphrase field ([05](android/05-unlock-prefilled.png)) and the SSH password field
|
||||||
|
([10](android/10-connect-sheet.png)) both report `password="false"` in the a11y tree. Their `text`
|
||||||
|
attribute is the secret itself, in clear.
|
||||||
|
- **Evidence:** the a11y tree on both screens; the values matched exactly what was typed (redacted here).
|
||||||
|
- **Why it matters:** any installed app holding an accessibility service can read every passphrase and
|
||||||
|
host password typed into DodoSSH. That's a common Android malware technique. TalkBack would also read
|
||||||
|
the passphrase aloud character by character.
|
||||||
|
- **Where:** `TextBox.secret` sets `PasswordChar` and `ContentType=Password`
|
||||||
|
(`Theme/Phone.axaml:583`). That's correct for the input method, but Avalonia's Android automation peer
|
||||||
|
ignores it.
|
||||||
|
- **Fix:** give secret boxes an automation peer that reports `IsPassword` and returns no value. At
|
||||||
|
minimum, override the exposed text on `TextBox.secret`. File it upstream with Avalonia as well. Check
|
||||||
|
the desktop's UIA peer for the same leak.
|
||||||
|
|
||||||
|
### H2 · The passphrase survives enrolment and pre-fills the unlock box
|
||||||
|
|
||||||
|
- **What:** right after the recovery code, the unlock screen already holds the passphrase (16 dots;
|
||||||
|
[05](android/05-unlock-prefilled.png)) beside "Unlock with the passphrase you just chose."
|
||||||
|
- **Why:** `ConfirmRecoveryCode` clears `RecoveryCode` from memory on purpose ("keeping it in a view
|
||||||
|
model … would undo that"), but `Passphrase` is never cleared after enrolment. Combined with H1, it's
|
||||||
|
readable from the a11y tree on that screen. It also skips the one moment that proves the user
|
||||||
|
remembers the passphrase.
|
||||||
|
- **Fix:** clear `Passphrase` and its confirmation when enrolment completes, as the recovery code is
|
||||||
|
cleared.
|
||||||
|
|
||||||
|
### H3 · A hardware Enter that submits the connect sheet leaves the window "not responding"
|
||||||
|
|
||||||
|
- **What:** I typed the password and pressed Enter (an injected `KEYCODE_ENTER`, which is what a
|
||||||
|
Bluetooth or DeX keyboard sends). The connection started, but Android reported the window
|
||||||
|
unresponsive. The key-up was never acknowledged (`InputDispatcher: Waited 5009ms for
|
||||||
|
KeyEvent … ENTER … action=UP`). The ANR dialog then kept coming back
|
||||||
|
([12](android/12-anr.png)) until the process was killed. Meanwhile the main thread was idle in
|
||||||
|
`Looper.pollOnce`: it wasn't busy, the event was simply lost.
|
||||||
|
- **Not affected:** tapping **CONNECT**, and the soft keyboard's ✓ (which sends an editor action, not a
|
||||||
|
key event). Both connected cleanly.
|
||||||
|
- **Likely cause:** focus moves to the terminal's native renderer (`Platform/RendererFocus.cs`) while
|
||||||
|
the Enter key-up is still in flight.
|
||||||
|
- **Repro:** host row → type password → hardware Enter.
|
||||||
|
- **Confidence:** reproduced once, and an accessibility dump was running at the time. Confirm on a real
|
||||||
|
device with a keyboard.
|
||||||
|
|
||||||
|
### H4 · Screen-reader names are type names across the app
|
||||||
|
|
||||||
|
The a11y tree announces these:
|
||||||
|
|
||||||
|
| Control | Announced as |
|
||||||
|
|---|---|
|
||||||
|
| Bottom nav (Hosts / Connections / Settings) | `Avalonia.Controls.Panel` ×3 |
|
||||||
|
| Add-sheet rows, Settings rows, session chip | `Avalonia.Controls.StackPanel` / `Avalonia.Controls.Grid` |
|
||||||
|
| Keychain filter chips | `Avalonia.Controls.StackPanel` ×5 |
|
||||||
|
| Host list rows | `DodoSSH.Client.Shell.ViewModels.HostRowViewModel` |
|
||||||
|
| Host editor dropdowns | `AuthenticationChoice { Kind = Typed, EntityId = , … }`, `GroupChoice { … }` |
|
||||||
|
| Vault row | `VaultRowViewModel { VaultId = 01a0fe77-… }` (leaks an internal id) |
|
||||||
|
| FAB / header add | `+` (no name) |
|
||||||
|
| Decorative glyphs | `⌕ ▤ ⌗ ⚙ ⚿ ❯ ⇅ ◳ ≡ ◎ ›` exposed as text |
|
||||||
|
|
||||||
|
There are two further problems:
|
||||||
|
- **Hidden content:** the hosts list stays in the tree under the full-screen host editor, so a
|
||||||
|
screen-reader user can wander into a screen they can't see.
|
||||||
|
- **Leftover desktop controls:** the desktop scrollbar's "Line up / Page down" buttons are exposed too.
|
||||||
|
|
||||||
|
**Fix:** set `AutomationProperties.Name` on every button whose content isn't a string. Give row
|
||||||
|
templates a name, or override `ToString` on the row view models. Mark glyph `TextBlock`s
|
||||||
|
`AutomationProperties.AccessibilityView="Raw"`. Hide covered content from the tree while an editor
|
||||||
|
covers it.
|
||||||
|
|
||||||
|
### H5 · Copying the recovery code isn't marked sensitive
|
||||||
|
|
||||||
|
`CopyRecoveryCode` goes through Avalonia's plain `SetTextAsync` (`App.axaml.cs:187`). On Android 13+,
|
||||||
|
the system shows a clipboard preview of the copied text. That overlay is system UI, so this screen's
|
||||||
|
`FLAG_SECURE` doesn't cover it, and Gboard keeps the text in its clipboard history. Use a native
|
||||||
|
`ClipData` with `ClipDescription.EXTRA_IS_SENSITIVE` for any secret the app copies. (Read from code;
|
||||||
|
not observed, because the screen can't be captured.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium — visual system
|
||||||
|
|
||||||
|
### M1 · The accent glow is clipped to a square on every primary button and the FAB
|
||||||
|
|
||||||
|
Each gradient button sits on a slightly lighter rectangle, and its corners show past the rounded shape:
|
||||||
|
- [z-glow-button](android/z-glow-button.png): SIGN IN, CREATE THE VAULT, UNLOCK, CONNECT, SAVE, ADD
|
||||||
|
FILES;
|
||||||
|
- [z-glow-fab](android/z-glow-fab.png): the round FAB sits inside a visible square.
|
||||||
|
|
||||||
|
`AccentGlow` (`0 0 22 0`) is set on the template's `ContentPresenter`
|
||||||
|
(`Phone.axaml` `Button.primary` / `Button.fab /template/ ContentPresenter`) and gets clipped to the
|
||||||
|
button's own bounds. The desktop's `Button.accent` uses the same pattern, so check it there too. Fix:
|
||||||
|
draw the glow from an unclipped wrapper (a `Border` with margin), or drop it on the phone.
|
||||||
|
|
||||||
|
### M2 · Body copy is monospace, against the shared-typeface decision
|
||||||
|
|
||||||
|
`docs/design-import-gaps.md` ("The phone catches up") records that the phone shares the desktop's
|
||||||
|
faces. On the desktop, prose (`hint`, `heading`, the settings titles and captions) is Montserrat, and
|
||||||
|
mono is kept for identifiers (`.mono`) and labels (`.label`). On the phone, `TextBlock.body`, `.title`
|
||||||
|
and `.detail` all still set `MonoFont` (`Phone.axaml:661–680`). So every paragraph, title and caption is
|
||||||
|
JetBrains Mono, and only screen headers (Personal, Settings, New host) are Montserrat. The result is
|
||||||
|
long, wide-set paragraphs that are tiring to read ([03](android/03-enrol.png),
|
||||||
|
[16](android/16-settings.png), [19](android/19-sftp.png)).
|
||||||
|
|
||||||
|
Keep mono for labels, buttons (the recorded ALL-CAPS-mono convention), addresses and code. Move
|
||||||
|
`.body`, `.title` and `.detail` to the default sans.
|
||||||
|
|
||||||
|
### M3 · Fluent's selection fill leaks into the host list
|
||||||
|
|
||||||
|
After a connect, the tapped row stays lit in `#3d486b` ([15](android/15-selected-row.png)). That
|
||||||
|
slate-blue isn't in `Palette.axaml`, which says selection is `Active` `#1E1E2C`. The comment at
|
||||||
|
`HostsScreen.axaml:360` says the selection fill was deliberately removed ("a lit row would be a mark
|
||||||
|
with no action behind it"). But only the custom `Border.hostrow.chosen` style was changed. Fluent's
|
||||||
|
`ListBoxItem:selected /template/ ContentPresenter` background was never overridden.
|
||||||
|
|
||||||
|
### M4 · Disabled secondary buttons are heavier than enabled ones
|
||||||
|
|
||||||
|
`Button.secondary` has no `:disabled` style, so Fluent's `#3c3c43` slab shows through. SFTP's disabled
|
||||||
|
**SAVE FILE** is the most prominent button in its row ([z-sftp-actions](android/z-sftp-actions.png)).
|
||||||
|
Mirror `Button.primary:disabled`: a `Raised` fill and `TextFaint` text.
|
||||||
|
|
||||||
|
### M5 · Unstyled Fluent controls among styled ones
|
||||||
|
|
||||||
|
- **Dropdowns:** `ComboBox` keeps Fluent's ~4 px radius, bright white border and black fill beside
|
||||||
|
10 px, softly bordered fields (host editor, SFTP host picker; [08](android/08-host-editor.png)).
|
||||||
|
- **Checkboxes:** the default `CheckBox`, with its label sitting below the box's centre (relay, remember
|
||||||
|
password, press-Enter; [10](android/10-connect-sheet.png)).
|
||||||
|
- **Focused fields:** the fill switches from `Field` to pure `#000`.
|
||||||
|
- **Scrollbars:** desktop scrollbars are always visible. On Keychain, the horizontal bar is drawn over
|
||||||
|
the bottom of the filter chips ([17](android/17-keychain.png)). Android expects auto-hiding overlay
|
||||||
|
bars.
|
||||||
|
- **Stuck hover:** after a tap, Vaults' ↻ keeps a grey hover square. Touch has no hover, so
|
||||||
|
`:pointerover` styles need a touch-safe variant.
|
||||||
|
|
||||||
|
### M6 · The terminal's colour and font have drifted from the palette
|
||||||
|
|
||||||
|
`WebAssets/terminal.css` says its `#171a26` background "is `TerminalSurface` in Palette.axaml". The
|
||||||
|
palette now says `#18161F`. The terminal therefore reads navy, against the app's purple-black
|
||||||
|
([14](android/14-terminal.png)). Its font stack starts at `ui-monospace` and never names the embedded
|
||||||
|
JetBrains Mono, so on Android the WebView falls back to the system mono. That makes two different
|
||||||
|
monospace faces on one screen.
|
||||||
|
|
||||||
|
### M7 · Iconography
|
||||||
|
|
||||||
|
- **Glyphs, not icons:** the phone uses Unicode glyphs (`⚿ ❯ ⇅ ◳ ≡ ◎ ⚙ ▤ ⌗ ⌕`), even though Material
|
||||||
|
Icons is embedded and the desktop uses it. Weights and optical sizes vary (the heavy purple `❯` beside
|
||||||
|
hairline `◳`; [16](android/16-settings.png)).
|
||||||
|
- **Reused meanings:** ⚙ is both the Settings tab and the Preferences row, and ⚿ is both Keychain and
|
||||||
|
"Lock the keychain".
|
||||||
|
- **Splash:** the splash shows a flat purple circle, while the app's own icon is a rounded square with
|
||||||
|
the gradient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium — information architecture and flows
|
||||||
|
|
||||||
|
### M8 · "Settings" holds the product
|
||||||
|
|
||||||
|
Keychain, Snippets, SFTP, S3, Logs and Vaults all sit under the ⚙ **Settings** tab, beside
|
||||||
|
Preferences and Lock ([16](android/16-settings.png)). File transfer and snippets are working tools, not
|
||||||
|
settings. Options:
|
||||||
|
- rename the tab ("More" or "Tools") and keep Preferences as one row in it;
|
||||||
|
- or promote SFTP to the bottom nav. Its icon is already ⇅.
|
||||||
|
|
||||||
|
### M9 · Keychain can't add anything
|
||||||
|
|
||||||
|
The empty state says "Add an SSH key or a password to stop typing one" ([17](android/17-keychain.png)).
|
||||||
|
The screen has no add control: no FAB, no header +, nothing in the a11y tree. Passwords can only be
|
||||||
|
created from the host editor's "+ NEW CREDENTIAL", and no phone path adds an SSH key. If keys are
|
||||||
|
desktop-only, the empty state should say so.
|
||||||
|
|
||||||
|
### M10 · Three ways to add, two ways to edit
|
||||||
|
|
||||||
|
- **Add:** Hosts has a FAB, Snippets a header "+", Keychain nothing, and the terminal's header "+"
|
||||||
|
means "new session".
|
||||||
|
- **Edit:** the host editor is a full-screen page with ← and ✓ ([08](android/08-host-editor.png)). The
|
||||||
|
snippet editor is an inline card with **SAVE/CANCEL**, and the header "+" stays live while it's open
|
||||||
|
([18](android/18-snippet-editor.png)).
|
||||||
|
|
||||||
|
Pick one pattern for each.
|
||||||
|
|
||||||
|
### M11 · SFTP
|
||||||
|
|
||||||
|
- **Action block:** it covers more than half the screen (seven buttons, a permanent help paragraph and a
|
||||||
|
status line), so only about four file rows are visible ([19](android/19-sftp.png)).
|
||||||
|
- **No selection:** RENAME, MOVE, PERMS and DELETE are enabled with nothing selected. Tapping one only
|
||||||
|
writes "Choose something on the host to rename." into the status line at the bottom.
|
||||||
|
- **DELETE:** neutral-styled here, while delete is danger-red on hosts and the keychain.
|
||||||
|
- **Directory rows:** marked only by a tiny purple ▸, with no folder icon.
|
||||||
|
- **Password again:** it's asked for again although a shell to the same host is already open.
|
||||||
|
- **S3:** blocked while an SFTP session is open. Tapping S3 bounces to SFTP, with "An SFTP session is
|
||||||
|
open. Close it before opening a bucket." at the bottom ([20](android/20-s3-bounced.png)).
|
||||||
|
|
||||||
|
Suggestion: a contextual action bar that appears with a selection, help moved behind an ⓘ, and the
|
||||||
|
listing given the screen.
|
||||||
|
|
||||||
|
### M12 · Bottom sheets
|
||||||
|
|
||||||
|
- **Partial scrim:** the scrim covers only the content area. The header (with **LOCK**) and the bottom
|
||||||
|
nav stay bright and tappable under every sheet ([07](android/07-add-sheet.png),
|
||||||
|
[10](android/10-connect-sheet.png)).
|
||||||
|
- **Decorative handle:** it has a drag handle, but swipe-down doesn't dismiss.
|
||||||
|
- **Button order:** the primary action is on the left (CONNECT | CANCEL, SAVE | CANCEL). Android puts
|
||||||
|
the confirming action on the right.
|
||||||
|
- **No autofocus:** the connect sheet's password field isn't focused on open, so it takes an extra tap.
|
||||||
|
- **Nav above keyboard:** the bottom nav rides up above the keyboard and eats the sheet's space
|
||||||
|
([13 before trust](android/13-host-key.png)).
|
||||||
|
|
||||||
|
### M13 · Sign-in and enrolment
|
||||||
|
|
||||||
|
- **Full browser:** sign-in opens a full Chrome tab (`ChromeTabbedActivity`), not a Custom Tab, so the
|
||||||
|
user leaves the app. Chrome's own first-run prompts then land on top.
|
||||||
|
- **Unannounced second sign-in:** step 2 asks for a passphrase, and **CREATE THE VAULT** then sends the
|
||||||
|
user to the identity provider's login form a second time. That's the deliberate `prompt=login` key
|
||||||
|
binding (`OidcClient.AuthorizeKeyBindingAsync`), but no copy warns that it's coming. Say "you'll
|
||||||
|
confirm your identity once more" on the passphrase screen.
|
||||||
|
- **Blank resume:** after the browser returns, the app showed a blank `#05050a` canvas for 5–10 s with
|
||||||
|
no progress indicator. Every shell state has a screen, so this looks like surface re-creation on
|
||||||
|
resume (Debug + emulator). Confirm on Release, and keep a visible state on screen while it happens.
|
||||||
|
- **Step labels:** start at "STEP 2 OF 3"; step 1 is never labelled.
|
||||||
|
|
||||||
|
### M14 · The global status line leaks across screens
|
||||||
|
|
||||||
|
A single `StatusMessage` follows the user around:
|
||||||
|
- "Unlocked 'Personal'." in the middle of Preferences ([21](android/21-preferences.png));
|
||||||
|
- "Synchronised: 2 in, 2 out." after adding one host. `Describe` counts `Pulled`/`Pushed`, which include
|
||||||
|
the app's own log entries, while `PulledItems`/`PushedItems` already exist for this;
|
||||||
|
- "audit-box asks for a password." repeated under the sheet that asks for it.
|
||||||
|
|
||||||
|
Scope messages to the screen that raised them, or use transient snackbars.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Low — input, copy, polish
|
||||||
|
|
||||||
|
- **Keyboards:**
|
||||||
|
- the server URL field gets a text keyboard with contact suggestions, not the URL keyboard
|
||||||
|
([02](android/02-server-keyboard.png));
|
||||||
|
- the port field gets the full letter keyboard ([z-port-keyboard](android/z-port-keyboard.png));
|
||||||
|
- hostname and username get autocorrect suggestions.
|
||||||
|
|
||||||
|
Set `TextInputOptions.ContentType` (`Url`, `Digits`) and turn autocorrect off for those.
|
||||||
|
- **Placeholder-as-label:** name, hostname, username and notes in the host editor, the unlock field,
|
||||||
|
and the snippet fields have no labels, so each field's meaning disappears once typed in.
|
||||||
|
- **Terminology:**
|
||||||
|
- "CREATE THE VAULT", then "Creating your keychain";
|
||||||
|
- "vault passphrase" (placeholder) next to "Enter your keychain passphrase" on the same screen;
|
||||||
|
- "ENROLLMENT" (US spelling) in a British-English app ("Synchronised", "unopenable").
|
||||||
|
- **Fingerprint wrapping:** a single "I" wraps onto its own line. Chunk it, or size it to fit
|
||||||
|
([13](android/13-host-key.png)).
|
||||||
|
- **Address format:** "10.0.2.2 · 2222" on the host-key sheet, "10.0.2.2:2222" everywhere else.
|
||||||
|
- **Dates:** "10/2/2026 11:29 PM" in Logs ([23](android/23-logs.png)), "2026-10-02 21:04" in SFTP.
|
||||||
|
- **Notification permission:** Android's prompt appears without explanation at the first connect.
|
||||||
|
Explain the keep-alive notification first.
|
||||||
|
- **Terminal width:** the default size gives about 37 columns, so `ls -la` wraps every line.
|
||||||
|
- **Empty and loading states:**
|
||||||
|
- Hosts' empty state is one grey line at the top left ([06](android/06-hosts-empty.png));
|
||||||
|
- Vaults first rendered an empty list with no loading indicator;
|
||||||
|
- the keychain shows both "0 items" and an empty-state sentence.
|
||||||
|
- **Connecting card:** **CLOSE THIS TAB** and **SHOW LOGS** are indented past the card's left edge
|
||||||
|
([11](android/11-connecting.png)).
|
||||||
|
- **Host-key context:** the sheet opened over the Quick Connect form, not over the connection progress
|
||||||
|
it belongs to.
|
||||||
|
- **Disabled fields:** on the enrolment busy state they turn a flat mid-grey
|
||||||
|
([04](android/04-enrol-busy.png)), and the keyboard stays up.
|
||||||
|
- **Fingerprint setting:** "Unlock with a fingerprint" in Preferences is drawn as a bare circle; it reads
|
||||||
|
as a radio button, not a switch.
|
||||||
|
|
||||||
|
## Not verified here
|
||||||
|
|
||||||
|
- **System font size:** whether the app honours it. No `FontScale` handling exists in the phone code,
|
||||||
|
and Avalonia scales by density, so text likely ignores the setting. I didn't change device settings
|
||||||
|
to confirm.
|
||||||
|
- **Landscape and tablet:** an `IsWide` layout exists but wasn't exercised.
|
||||||
|
- **TalkBack:** not run end to end. The H4 findings come from the tree TalkBack reads.
|
||||||
|
|
||||||
|
## Environment notes for a re-run
|
||||||
|
|
||||||
|
- **Keycloak port:** 18080 was taken by another project's container, so Keycloak ran on **18180** via a
|
||||||
|
compose override, with `Oidc__Authority=http://127.0.0.1:18180/realms/dodossh` and
|
||||||
|
`adb reverse tcp:18180`.
|
||||||
|
- **`adb root`** restarts adbd and drops `adb reverse`. Re-add both forwards afterwards, or the app
|
||||||
|
shows OFFLINE.
|
||||||
|
- **The sshd** was a throwaway `linuxserver/openssh-server` container (`dodossh-audit-sshd`, port 2222,
|
||||||
|
user `dodo`).
|
||||||
|
- **Build:** `-p:RuntimeIdentifier=android-x64` adds an `android-x64` section to every
|
||||||
|
`packages.lock.json`. Restore those files afterwards.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# DodoSSH full sweep — 2026-10-02
|
||||||
|
|
||||||
|
## Fix status (same day)
|
||||||
|
|
||||||
|
Every fix below has a regression test. Where marked ✔✔, the test was also checked to fail with the fix taken out.
|
||||||
|
|
||||||
|
| Item | Fix |
|
||||||
|
|---|---|
|
||||||
|
| C1 ✔✔ | `LocalDirectory.ChildPath` cleans remote names and requires the resolved parent to be the target folder. It is used by every download path and by EDIT. `LocalEditor` opens a file that would run (PATHEXT plus a known list, and macOS launchers) with the type's own `edit` verb or with Notepad / `open -t`. On Linux, opening a `.desktop` file is refused. |
|
||||||
|
| C2 ✔✔ | Pinned key types are offered first. A key under an algorithm with no pin of its own is accepted only if its fingerprint matches one of the endpoint's pins (the same RSA key under `rsa-sha2-256` vs `-512`); otherwise it is a hard mismatch. Tested against the real OpenSSH container. |
|
||||||
|
| C3 ✔✔ | Complete, Revise and Park take the `PendingOperation` and act only while the row still holds that operation id. A later edit is rebased onto the version that was just accepted, so it pushes without a conflict. |
|
||||||
|
| C4 | Pull leaves out change entries the item has moved past, so a revision is never paired with newer ciphertext. This also stops re-sending an item's payload once per historical edit. |
|
||||||
|
| C5 | Deleting a team's last vault archives the team only when the owner does it. `TeamService.ArchiveAsync` now checks ownership itself. |
|
||||||
|
| C6 | `DeviceKeyNames`: the default profile keeps `DodoSSH.DeviceKey.v1`, and every other profile gets its own name. Sign-out deletes only its own key. A profile still sealed under the shared key keeps reading it. The same applies to the macOS keychain tag. |
|
||||||
|
| H1–H3 | The data plane reassembles messages up to 16 MiB. A frame that throws stays with its own session. Input writes are serialized per pump, so a slow write is never overtaken. |
|
||||||
|
| H4 | The host and bucket the user chose are remembered across reloads. A typed password is only sent to the host it was typed for. |
|
||||||
|
| H5 | Selecting a vault reads its people even while the screen is busy. A generation counter discards answers for an older selection. |
|
||||||
|
| H6 / U9 | Locking or signing out leaves Settings, and the Members panel closes with it. Escape closes the Members panel first. |
|
||||||
|
| H7 ✔✔ | The tracked user is reloaded after the out-of-band last-seen update. |
|
||||||
|
| H8 | `sync_state.pulled_types` (with a client migration) restarts the pull once when the synced type list changes. |
|
||||||
|
| H9 | `ActiveAccountRequirement` on both policies. A suspended account gets 403 `account-not-active`. |
|
||||||
|
| H10 | `DevicePinWindow` palette keys fixed, and it now uses `InitializeComponent`. `Load` never assigned the named fields, so the window could never have opened. Covered by a new layout test. |
|
||||||
|
| H11 ✔✔ | `IRemoteFileStore.CanWriteFromAnOffset`: false for buckets, so a retried upload starts over. |
|
||||||
|
| H12 | `ImportOutcome` carries refused hosts and the reason a batch stopped early, including a busy keychain. Both import screens say so. |
|
||||||
|
| U1–U4 | The host card's sync badge moved to the address line, so names show in full. Tag chips wrap and trim. Keychain names trim inside their column, with a tooltip. The Security caption wraps. |
|
||||||
|
|
||||||
|
Second round, on branch `claude/audit-followups`:
|
||||||
|
|
||||||
|
| Item | Fix |
|
||||||
|
|---|---|
|
||||||
|
| U5 ✔✔ | The closed vault's card takes the keyboard on launch and on each new card (server address, enrolment, passphrase). Enter signs in and enrols. |
|
||||||
|
| U6 ✔✔ | The host-key, delete and sign-out questions give the keyboard to their harmless answer (CANCEL / CLOSE, never TRUST or DELETE). Escape says no to each, and closes the hosts drawer. Tab stays inside the host-key card. |
|
||||||
|
| U7 ✔✔ | Arrow-key selection on the Hosts board is written back to the vault, from the list that has the keyboard only. An arrow on the board itself enters the cards. Enter on a card connects. |
|
||||||
|
| U8 | `DeviceUnlockWording`: Windows Hello, Touch ID, a TPM-checked device PIN or a fingerprint, according to the platform. |
|
||||||
|
| U10 | Session tab labels are capped at 200 px and trimmed, with the full label in a tooltip. |
|
||||||
|
| U11 | The keychain's VAULT column is an Auto column in a shared size group, so it takes no width when no row names a vault. The Tags page's always-empty column is gone. The snippet buttons appear only with a snippet chosen. |
|
||||||
|
| U12 ✔✔ | `TerminalTabViewModel.EndedAt`: the status-bar timer stops when the shell ends. |
|
||||||
|
| P1 (part) ✔✔ | A sync pass that moved nothing no longer reloads (and decrypts) the whole keychain. It restrings the ago-texts instead. Failed vaults still reload. The Clear-plus-Add rebuild itself is unchanged. |
|
||||||
|
| P3 | `ProbedDeviceKeyStore`: the TPM probe runs once per process, on the thread pool. |
|
||||||
|
| P5 | A pulled page is mirrored in one write, and the outbox is read once per page. Changes with a local edit waiting still go one at a time, in order. |
|
||||||
|
| P6 ✔✔ | `OpenShellAsync` makes its three round trips on the thread pool, honours cancellation, and closes a shell that arrives after the caller gave up. |
|
||||||
|
| Transfers ✔✔ | Lost wake-up fixed with a `pumping` flag cleared under the same lock that finds the queue empty (a stress test failed 2 of 2 runs before the fix). Cancel as a transfer finishes no longer throws. A drop or download refused while busy says so. |
|
||||||
|
| EF retry ✔✔ | `FreshAttempts.ExecuteFreshAsync`: a retried write transaction clears the tracker and reloads what it carries in. Before this, a lost commit made a team hand-over answer 204 with nothing changed. Tested with an interceptor that fails one commit. |
|
||||||
|
| Rejected delete ✔✔ | A delete of a create that never landed is complete when the server says it has no such item. A create that did land still behaves as before: the delete gives way, and the user is told. |
|
||||||
|
| Team archive race | Archive and team-vault creation take a per-team advisory lock. Archive re-counts the team's vaults, and creation re-checks the team is live. No race test. |
|
||||||
|
| Relay revocation ✔✔ | `RelayAccessWatch` re-checks a user's open relay sessions when their access changes and ends lost ones with 4403 / `AccessRevoked`. |
|
||||||
|
| Shell | `ToggleVaultAsync` catches a failure and redraws. `CrashLog` writes uncaught exceptions to `crash.log` in the profile (the process still exits). A lock pressed during an unlock now wins (✔✔). |
|
||||||
|
| Window chrome | `CaptionPresses` decides a double-click on the press, before `BeginMoveDrag`. Only a headless test covers it; it is not verified on a real Windows window. |
|
||||||
|
|
||||||
|
| P1 (rest) ✔✔ | `ReplaceableCollection.ReplaceAll`: the host, visible-host, section and bucket lists are swapped in whole and announced once, and each transfers picker refreshes only from its own list. |
|
||||||
|
| P2 (most) | The transfer queue and import lists are virtualised (✔✔: 2,000 of 2,000 rows were built before). The find box hands its text to the vault after a 150 ms pause in typing, and the view model still filters synchronously. |
|
||||||
|
|
||||||
|
| P2 (board) ✔✔ | `VirtualizingWrapPanel` builds only the host cards near the view. Measured headless, after warm-up: first layout with 300 hosts went from about 3 s to about 0.5 s, and with 1,000 from 8.3 s to 0.2 s. Clearing a filter went from 1–8 s to about 0.1–0.2 s. Arrow keys, End, the band and selection still work. |
|
||||||
|
|
||||||
|
| P4 ✔✔ | **Server:** `TombstoneCollector` runs every 6 h. It deletes tombstones past the 90-day retention together with their change entries, and compacts each item's superseded entries down to its newest. It then raises the vault's `SyncHorizon`, and a cursor from before it is refused. A cursor now carries the horizon it was issued under. Without that, the read from the beginning that a refused client starts was refused on its own second page and looped for ever on any vault larger than a page (✔✔: found by a client test, then reproduced in an API test). **Client** ("resync and prune", as chosen): a read from the beginning marks every item it reads, keeping the mark in the sync state because the read can span passes. When it finishes, it removes unmarked items of the synced types. It keeps items with an unsent change, and items pushed during the read. Such an edit is then sent as a create, as before, but now records a `RecreatedAfterCollection` conflict and is named in the sync message. The removed items are named too. New client cache migration `AddSweepMarks`. |
|
||||||
|
|
||||||
|
Not done, and why:
|
||||||
|
- **Live-run observations** (production as the default server URL, Keycloak on host port 18080): product decisions, not bugs.
|
||||||
|
|
||||||
|
Pre-existing flakiness: on untouched HEAD, 3 of 8 runs of `ShellFlowTests` each had one test fail: `TheCountsAPhoneShows_FollowEveryForwardsState`, `ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself` and `ReconnectingAHostThatWantsAPassword_AsksForOneRatherThanDiallingWithout`. These races are in the tests and predate this work.
|
||||||
|
|
||||||
|
How it was done:
|
||||||
|
- The desktop client ran on Windows with an isolated profile against a local stack: Postgres, Keycloak and the API on :5333.
|
||||||
|
- The real MainWindow was rendered headless through every screen and settings page, 39 captures at 1081×583 and 1440×900.
|
||||||
|
- Four parallel code audits covered: the Shell view models; SSH/terminal/transfer; the server and sync; the desktop views and platform code.
|
||||||
|
- The findings marked **✔ verified** were re-read in code by me, or seen in a screenshot. The rest come from the audits; each one was traced, but I did not check them again.
|
||||||
|
|
||||||
|
The live session could not get past OIDC sign-in. The default browser is Edge, which I can't type into, so the terminal, SFTP and host-key flows were reviewed in code only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical — data loss, security
|
||||||
|
|
||||||
|
| # | Finding | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| C1 | **Path traversal on download / open-to-edit.** Remote names go straight into `Path.Combine(LocalPath, row.Name)`. SFTP only drops `.` and `..`, so on Windows a name with backslashes (`..\..\AppData\…\Startup\x.bat`) or a rooted name escapes the folder. S3 keys `../..` do the same on every OS. EDIT then opens the file with `UseShellExecute`, so a `.bat`/`.hta` *runs*. The Android head sanitises names; the desktop doesn't. ✔ verified | `TransfersViewModel.cs:2682,2698,2780,2784,3165`; `SshNetSftpSession.cs:105`; `ObjectKeys.cs:50`; `App.axaml.cs:380` |
|
||||||
|
| C2 | **Host-key pin bypass by changing the key algorithm.** Pins are looked up per algorithm. A MITM that offers ecdsa or rsa to a host pinned for ed25519 gets the "never seen before" first-use prompt instead of the mismatch hard block. ✔ verified | `SshNetConnectionFactory.cs:261-272` |
|
||||||
|
| C3 | **The outbox loses an edit made while a push is in flight.** `CompleteAsync` deletes the row by `Sequence` only, but `QueueAsync` folds a newer edit into that same row with a new `OperationId`. When the older push is acknowledged, the newer edit is deleted silently. ✔ verified | `OutboxStore.cs:239-250` vs `:71-97`; `SyncEngine.cs:554` |
|
||||||
|
| C4 | **Pull pairs historical revision numbers with the current ciphertext.** `Describe` sends `Version: change.Revision`, but the payload comes from the item's current row. The AAD binds the version, so the payload fails to decrypt. A concurrent offline edit gets parked as "Undecryptable". ✔ verified | `SyncService.cs:565,592` |
|
||||||
|
| C5 | **A team admin can archive the team (meant to be owner-only).** If an admin deletes the last vault, `ArchiveAsync` runs with no role check and revokes every membership, the owner's included. | `VaultGrantEndpoints.cs:182,196`; `TeamService.cs:242-289` |
|
||||||
|
| C6 | **The shared TPM key name breaks other profiles.** `WindowsDeviceKeyStore` uses one CNG key name (`DodoSSH.DeviceKey.v1`) for the release and nightly profiles. Sign-out in one (`ForgetAsync`) deletes the key, and the other profile's device unlock stops working. This contradicts `ClientPaths.ForChannel`, which says the two installs are kept apart. ✔ verified | `WindowsDeviceKeyStore.cs:127,233-246` |
|
||||||
|
|
||||||
|
## High — broken behaviour
|
||||||
|
|
||||||
|
| # | Finding | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| H1 | **Terminal input >64 KiB (a large paste or copy) is lost.** On a frame that doesn't fit the buffer, the loop `continue`s, and the tail is then dispatched as if it were a frame. ✔ verified | `TerminalDataPlane.cs:465-472` |
|
||||||
|
| H2 | **Keystrokes can race.** Input frames are written to `ShellStream` without awaiting and without a lock, and the base `FlushAsync` runs on the pool. A snippet RUN (text, then `\r`) over about 4 KiB can execute truncated. | `TerminalDataPlane.cs:505`; `SshNetConnectionFactory.cs:554-558` |
|
||||||
|
| H3 | **One resize on a closing shell tears down the renderer socket for every tab** (`ObjectDisposedException` escapes the receive loop). | `TerminalDataPlane.cs:516-520` |
|
||||||
|
| H4 | **The SFTP host picker resets to the first host on every sync pass**, about once a minute. Selection is lost on `Hosts.Clear()`, so CONNECT can dial host[0] with the password typed for host X. The same Clear-then-Add pattern resets the Known Hosts and Snippets selection and closes the snippet delete card. | `TransfersViewModel.cs:3905,3923-3950`; `KnownHostsViewModel.cs:264`; `SnippetsViewModel.cs:689` |
|
||||||
|
| H5 | **The Vaults Members panel can show vault A's members under vault B**, and SHARE / REVOKE then act on that mix. | `VaultsViewModel.cs:1646,1698-1765` |
|
||||||
|
| H6 | **Locking or signing out from Settings leaves a window with no title bar** (no close, minimize or drag). Unlock then returns to Settings. ✔ verified by screenshot | `MainWindowViewModel.cs:3254,3400`; see `locked-from-settings-no-titlebar.png` |
|
||||||
|
| H7 | **Enrollment returns 500 if last-seen is more than an hour old.** `ExecuteUpdate` changes `xmin` under a tracked entity, and the `SaveChanges` that follows throws a concurrency exception. | `CurrentUserContext.cs:92,120`; `EnrollmentService.cs:177` |
|
||||||
|
| H8 | **A new synced type never back-fills.** A filtered pull moves the cursor past types the client didn't request, so after an upgrade adds a type (SpelunkKey, ObjectStore), older changes of that type never arrive. | `SyncService.cs:50,69`; `SyncEngine.cs:145` |
|
||||||
|
| H9 | **Suspended users keep full access.** `UserStatus` is checked nowhere on the request path. | `CurrentUserContext.cs:126` |
|
||||||
|
| H10 | **`DevicePinWindow` references `Surface` and `MutedText`, which don't exist**, so Linux TPM-PIN register/unlock should throw on open. ✔ verified | `DevicePinWindow.axaml:7,35` |
|
||||||
|
| H11 | **A failed S3 upload commits a truncated part, and every retry then fails.** | `S3UploadStream.cs:126-146` |
|
||||||
|
| H12 | **Import screens report success when the vault skipped or failed the import** (busy, or a mid-batch exception). | `ImportViewModel.cs:374`; `HetznerImportViewModel.cs:561` |
|
||||||
|
|
||||||
|
## UI issues seen in the running app
|
||||||
|
|
||||||
|
| # | Finding | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| U1 | **Host card labels are cut to 3-4 characters even at 1440px.** "bastion" shows as "bast…", and k8s-worker-a / -b both show as "k8s…", as do web-prod-01 / -02. The label is the primary identifier, and the "not synced" badge takes most of its width. ✔ `hosts-truncated-labels.png` | `HostsScreen.axaml` card template |
|
||||||
|
| U2 | **Tag chips overflow the host card edge.** The WrapPanel sits inside a horizontal StackPanel, so it never wraps. ✔ | `HostsScreen.axaml:737-774` |
|
||||||
|
| U3 | **Keychain table: long names overprint the TYPE / USED BY columns.** `TextTrimming` has no effect inside a horizontal StackPanel. The same pattern appears in Logs, SessionSidebar, Snippets, NavRail and Transfers. ✔ `keychain-overlap.png` | `KeychainScreen.axaml:294` et al. |
|
||||||
|
| U4 | **Security page: the "End-to-end encrypted" caption runs past the card edge.** ✔ `security-text-clipped.png` | `SettingsSecurityPage.axaml:49-60` |
|
||||||
|
| U5 | **Enter does nothing in the server URL box** (verified live). The same applies to the enrollment passphrase boxes. The passphrase box is not focused on launch. | `MainWindow.axaml:385` |
|
||||||
|
| U6 | **Overlay cards (host key, delete, sign-out, host editor) have no Enter/Escape and don't take focus**, and Tab walks the page behind them. | `HostKeyCard.axaml` etc. |
|
||||||
|
| U7 | **Hosts board: arrow-key selection isn't written back to the view model**, so the drawer's Connect / Delete act on the previously clicked host. There is no Enter-to-connect. | `HostsScreen.axaml:573` |
|
||||||
|
| U8 | **Windows-only wording on macOS/Linux:** "USE WINDOWS HELLO", "Waiting for Windows…". | `UnlockCard.axaml:51`; `MainWindowViewModel.GestureWait` |
|
||||||
|
| U9 | **Escape over the Members panel leaves Settings and leaves the panel open** for the next visit. | `MainWindow.axaml.cs:242` |
|
||||||
|
| U10 | **Session tab labels are unbounded.** One long label pushes "+" off the strip. | `SessionTabRow.axaml:89` |
|
||||||
|
| U11 | **A VAULT column is drawn and empty** with a single vault (Keychain, Tags). The snippets list footer shows Edit / Move / Delete with nothing selected. | screenshots |
|
||||||
|
| U12 | **The status-bar session timer keeps counting after a shell has ended.** | `MainWindowViewModel.cs:4979` |
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
| # | Finding | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| P1 | **Every sync pass, about once a minute, decrypts the entire keychain even when nothing changed**, then rebuilds each list with Clear + Add. That is O(n²) notification fan-out: with 300 hosts, roughly 45k collection notifications per pass. | `VaultViewModel.cs:6375,4709`; `MainWindowViewModel.cs:4549` |
|
||||||
|
| P2 | **The Hosts board is not virtualised and is rebuilt on every filter keystroke.** The transfer queue (one row per file in a folder download) and the import lists are not virtualised either. | `HostsScreen.axaml:448-576`; `TransfersScreen.axaml:1193` |
|
||||||
|
| P3 | **The TPM probe (create and delete an RSA-2048 key) runs synchronously on the UI thread** at startup and after every unlock. | `WindowsDeviceKeyStore.cs:152`; `MainWindowViewModel.cs:2594,2970` |
|
||||||
|
| P4 | **Pull re-sends the full current payload once per historical change.** There is no change-log compaction and no tombstone GC, even though ADR 0003 promises 90-day GC. | `SyncService.cs:45-56,565` |
|
||||||
|
| P5 | **The client applies each pulled change in 2 or more separate SQLite transactions.** | `SyncEngine.cs:157-169,303` |
|
||||||
|
| P6 | **`OpenShellAsync` does blocking network round trips wrapped in `Task.FromResult`** and ignores cancellation. | `SshNetConnectionFactory.cs:401-418` |
|
||||||
|
|
||||||
|
## Lower / plausible
|
||||||
|
|
||||||
|
- **Transfers:**
|
||||||
|
- A wake-up can be lost, leaving a transfer queued forever (`FileTransferQueue.cs:452-505`).
|
||||||
|
- Cancel at the moment a transfer finishes can throw `ObjectDisposedException`.
|
||||||
|
- Drops and downloads are ignored silently while busy (`TransfersViewModel.cs:2272`).
|
||||||
|
- **Sync and server:**
|
||||||
|
- The EF retry strategy re-runs a push with stale tracked state (`Persistence.cs:38`).
|
||||||
|
- Deleting a create that never landed leaves a permanent "Rejected" conflict.
|
||||||
|
- A team can be archived while a vault is being created in it.
|
||||||
|
- Relay sessions survive removal from the team, for up to 12 hours.
|
||||||
|
- **Shell:**
|
||||||
|
- Lock can race an unlock that is still loading.
|
||||||
|
- `ToggleVaultAsync` has no try/catch, and there is no global `UnhandledException` handler.
|
||||||
|
- **Window chrome:** double-click to maximise may be swallowed by `BeginMoveDrag`.
|
||||||
|
|
||||||
|
## Observations from the live run
|
||||||
|
|
||||||
|
- **The first-launch default is the production server, and sign-in completes without a credential prompt.** `DefaultServerUrl = "https://ssh.dodotech.cloud"` is pre-filled. With a live browser SSO session, one press of SIGN IN on a brand-new profile completed sign-in with no password typed, and wrote the account's wrapped key and vault list into the new profile. For a self-hosted product, an empty field with a placeholder is the safer default.
|
||||||
|
- **The local dev compose hard-codes host port 18080 for Keycloak**, which collides with other projects on this machine.
|
||||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -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
|
# 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
|
# name deliberately and not the package's binary name — the window does not know which package
|
||||||
# started it.
|
# 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
|
mkdir -p %{buildroot}%{_datadir}/applications
|
||||||
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
|
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
@@ -255,13 +262,14 @@ Version=1.0
|
|||||||
Name=%{dodo_title}
|
Name=%{dodo_title}
|
||||||
GenericName=SSH Client
|
GenericName=SSH Client
|
||||||
Comment=SSH and SFTP with an end-to-end encrypted credential vault
|
Comment=SSH and SFTP with an end-to-end encrypted credential vault
|
||||||
Exec=%{dodo_bin}
|
Exec=%{dodo_bin} %%u
|
||||||
Icon=%{dodo_bin}
|
Icon=%{dodo_bin}
|
||||||
Terminal=false
|
Terminal=false
|
||||||
StartupNotify=true
|
StartupNotify=true
|
||||||
StartupWMClass=DodoSSH
|
StartupWMClass=DodoSSH
|
||||||
Categories=Network;RemoteAccess;System;
|
Categories=Network;RemoteAccess;System;
|
||||||
Keywords=SSH;SFTP;Terminal;Remote;Shell;
|
Keywords=SSH;SFTP;Terminal;Remote;Shell;
|
||||||
|
MimeType=x-scheme-handler/dodossh;
|
||||||
DESKTOP
|
DESKTOP
|
||||||
|
|
||||||
# One file per size into the hicolor theme, named for the package so the two channels show different
|
# 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>
|
<key>LSUIElement</key>
|
||||||
<false/>
|
<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>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>© DodoTech. MIT licensed.</string>
|
<string>© DodoTech. MIT licensed.</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -68,7 +68,15 @@ the Npgsql connection string** — the default; do not enable multiplexing.
|
|||||||
visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
|
visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
|
||||||
- Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be
|
- Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be
|
||||||
able to read tombstones, which is the one place that legitimately bypasses the soft-delete
|
able to read tombstones, which is the one place that legitimately bypasses the soft-delete
|
||||||
query filter — guarded by an explicit permission check.
|
query filter — guarded by an explicit permission check. **The collection has since been built**
|
||||||
|
(`TombstoneCollector`), and it costs a machine away for longer than the retention its news of
|
||||||
|
those deletions. So a collection raises the vault's sync horizon, and a cursor from before it is
|
||||||
|
refused. The client then reads from the beginning, and when that read finishes it removes the
|
||||||
|
items it held that the read never mentioned. An item with an unsent local edit is kept: the
|
||||||
|
push finds the server without it, puts it back, and records a `RecreatedAfterCollection`
|
||||||
|
conflict. A cursor carries the horizon it was issued under. Without that, the read from the
|
||||||
|
beginning would be refused on its own early pages, which sit behind the horizon whenever an
|
||||||
|
older item survives.
|
||||||
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
|
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
|
||||||
Without it this ADR's central bug is invisible until production.
|
Without it this ADR's central bug is invisible until production.
|
||||||
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
|
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
|
||||||
|
|||||||
@@ -631,7 +631,8 @@ go at 360dp:
|
|||||||
for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together,
|
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
|
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
|
[`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
|
**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
|
SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and
|
||||||
|
|||||||
@@ -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,
|
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
|
||||||
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
|
`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
|
> **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
|
> `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
|
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
|
||||||
> `AadResourceTypeTests` pins each pairing by name for exactly that reason.
|
> `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.
|
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
|
||||||
|
|
||||||
### 4.4 What this prevents
|
### 4.4 What this prevents
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
>
|
>
|
||||||
> | v2 element | What ships instead |
|
> | 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. |
|
> | 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 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. |
|
> | `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. |
|
> | `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. |
|
> | 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. |
|
> | 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 |
|
> | 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 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. |
|
> | 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. |
|
> | 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 **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. |
|
> | 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. |
|
> | **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 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
|
> ## The desktop's v5 — hosts, restyled, and two reversals
|
||||||
@@ -295,7 +295,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> | v5b element | What ships instead |
|
> | 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. |
|
> | 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 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. |
|
> | 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. |
|
> | 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.
|
Three facts explain nearly every row below.
|
||||||
|
|
||||||
**An SSH connection here opens exactly one channel.** `ISshConnection` offers `OpenShellAsync` and nothing
|
**An SSH connection here opens a shell, and forwards beside it.** `ISshConnection` offers `OpenShellAsync`
|
||||||
else (`src/DodoSSH.Client.Ssh/SshConnection.cs`). No port forwarding, no ProxyJump. That one fact removes
|
and `StartForwardAsync` (`src/DodoSSH.Client.Ssh/SshConnection.cs`). There is still no ProxyJump, which is
|
||||||
the `FORWARDS` chip, the status bar's port list, and every `via bastion-eu` in the design.
|
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:
|
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
|
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
|
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.
|
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
|
`HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` and
|
||||||
reserved *against a decision* rather than pending one. Which hosts wear a tag is a set of ids inside each
|
`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
|
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
|
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.
|
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. |
|
| `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". |
|
| `⌘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 `· 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 `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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| `· ⤷ 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. |
|
| `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. |
|
| `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. |
|
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
||||||
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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.
|
/// 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
|
/// <para>
|
||||||
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
|
/// ◆ <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>
|
/// </remarks>
|
||||||
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -114,13 +120,18 @@ internal sealed class CurrentUserContext(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.Users
|
var written = await database.Users
|
||||||
.Where(u => u.Id == user.Id
|
.Where(u => u.Id == user.Id
|
||||||
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
||||||
.ExecuteUpdateAsync(
|
.ExecuteUpdateAsync(
|
||||||
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (written > 0)
|
||||||
|
{
|
||||||
|
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
||||||
|
|||||||
@@ -80,6 +80,26 @@ internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareR
|
|||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
ArgumentNullException.ThrowIfNull(authorizeResult);
|
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
|
var enrollmentMissing = authorizeResult.Forbidden
|
||||||
&& authorizeResult.AuthorizationFailure is { } failure
|
&& authorizeResult.AuthorizationFailure is { } failure
|
||||||
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
||||||
|
|||||||
@@ -2,9 +2,18 @@
|
|||||||
#
|
#
|
||||||
# docker build -f src/DodoSSH.Api/Dockerfile .
|
# docker build -f src/DodoSSH.Api/Dockerfile .
|
||||||
#
|
#
|
||||||
# Directory.Build.props, Directory.Packages.props, NuGet.config and global.json all sit at
|
# Directory.Build.props, Directory.Build.targets, Directory.Packages.props, NuGet.config and
|
||||||
# the root and all four are load-bearing here — central package management means a csproj
|
# global.json all sit at the root and all five are load-bearing here — central package
|
||||||
# alone does not name a single version, and a build that cannot see them resolves nothing.
|
# 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
|
# Build
|
||||||
@@ -26,7 +35,7 @@ WORKDIR /src
|
|||||||
# dependency change and by nothing else. Every project in DodoSSH.Api's reference closure
|
# 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
|
# has to be here — restore walks ProjectReference, and a missing csproj fails the graph
|
||||||
# rather than skipping a node.
|
# 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/DodoSSH.Api.csproj src/DodoSSH.Api/
|
||||||
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
|
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
|
||||||
COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/
|
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>
|
/// <summary>Runs the socket until something ends it, then closes it politely.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Three loops rather than one: reading a socket and writing to it are independent waits, and the
|
/// 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>
|
/// clock is a third. They are joined by <see cref="Task.WhenAny(Task[])"/>, and the sender is
|
||||||
/// awaited before the close is written, because a close frame racing a notice frame is a protocol
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
private async Task PumpAsync(
|
private async Task PumpAsync(
|
||||||
WebSocket socket,
|
WebSocket socket,
|
||||||
@@ -236,15 +249,21 @@ internal sealed class VaultEventsEndpoint(
|
|||||||
VaultCount: connection.VaultCount));
|
VaultCount: connection.VaultCount));
|
||||||
|
|
||||||
var sending = SendAsync(socket, connection, pump.Token);
|
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);
|
var minding = MindAsync(connection, closure, pump.Token);
|
||||||
|
|
||||||
await Task.WhenAny(sending, receiving, minding).ConfigureAwait(false);
|
await Task.WhenAny(sending, receiving, minding).ConfigureAwait(false);
|
||||||
|
|
||||||
await pump.CancelAsync().ConfigureAwait(false);
|
await pump.CancelAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
// Nothing may still be mid-send when the close frame goes out.
|
// Nothing may still be mid-send when the close frame goes out. A send that does not finish is
|
||||||
await Task.WhenAll(Settled(sending), Settled(receiving), Settled(minding)).ConfigureAwait(false);
|
// 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)
|
if (lifetime.ApplicationStopping.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -256,6 +275,14 @@ internal sealed class VaultEventsEndpoint(
|
|||||||
EventsLog.ClosingConnection(logger, connection.UserId, closure.Reason);
|
EventsLog.ClosingConnection(logger, connection.UserId, closure.Reason);
|
||||||
|
|
||||||
await CloseAsync(socket, closure).ConfigureAwait(false);
|
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>
|
/// <summary>
|
||||||
@@ -287,8 +314,9 @@ internal sealed class VaultEventsEndpoint(
|
|||||||
|
|
||||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent);
|
var bytes = JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent);
|
||||||
|
|
||||||
|
// Not cancellable, deliberately. See PumpAsync.
|
||||||
await socket
|
await socket
|
||||||
.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken)
|
.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,21 +325,26 @@ internal sealed class VaultEventsEndpoint(
|
|||||||
/// Reads what the client sends, which in this version is heartbeats and a close.
|
/// Reads what the client sends, which in this version is heartbeats and a close.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// A client cannot ask to follow a vault, and that is deliberate rather than unfinished: a
|
/// 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>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
|
/// <c>SyncPullEndpoint</c> answers 404 rather than 403 to avoid. Subscription is decided from the
|
||||||
/// caller's access and nothing else.
|
/// 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>
|
/// </remarks>
|
||||||
private static async Task ReceiveAsync(
|
private static async Task ReceiveAsync(WebSocket socket, VaultEventConnection connection)
|
||||||
WebSocket socket,
|
|
||||||
VaultEventConnection connection,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var buffer = new byte[MaxInboundFrameBytes];
|
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)
|
if (received.MessageType == WebSocketMessageType.Close)
|
||||||
{
|
{
|
||||||
@@ -492,31 +525,39 @@ internal sealed class VaultEventsEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
|
/// <returns>Whether it finished.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Every one of these loops ends by being cancelled or by the socket going away, so an exception
|
/// 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
|
/// 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.
|
/// propagating it would skip the close frame the other side is waiting for.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static async Task Settled(Task loop)
|
private static async Task<bool> SettlesAsync(Task loop)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await loop.ConfigureAwait(false);
|
await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
|
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>
|
/// <summary>Why the socket is being closed, decided by whichever loop ended first.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Mutable and shared, and safe without a lock for one specific reason: it is written by the pump
|
/// Mutable and shared, and safe without a lock for one specific reason: it is written only by
|
||||||
/// loops and read only after <see cref="Task.WhenAll(Task[])"/> over all of them, which is a
|
/// <see cref="MindAsync"/>, and read only after that loop has been awaited, which is a memory
|
||||||
/// memory barrier. Writes race only with each other, and any of them is a true answer.
|
/// barrier. If that loop outlived the close timeout, a late write races the read, and either
|
||||||
|
/// answer is still a true one.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private sealed class Closure(int code, string reason)
|
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.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Crypto;
|
using DodoSSH.Crypto;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
|
|||||||
byte[] fingerprint,
|
byte[] fingerprint,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
return await database.ExecuteFreshAsync([user], async () =>
|
||||||
|
|
||||||
return await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var lease = sessions.TryAdmit(ticket.UserId);
|
var lease = sessions.TryAdmit(ticket.UserId, ticket.VaultId);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -113,7 +113,7 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await RelayAsync(socket, ticket, ct).ConfigureAwait(false);
|
await RelayAsync(socket, ticket, lease.Revoked, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -239,7 +239,11 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
|
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
|
||||||
private async Task RelayAsync(WebSocket socket, RelayTicket ticket, CancellationToken requestAborted)
|
private async Task RelayAsync(
|
||||||
|
WebSocket socket,
|
||||||
|
RelayTicket ticket,
|
||||||
|
CancellationToken revoked,
|
||||||
|
CancellationToken requestAborted)
|
||||||
{
|
{
|
||||||
var started = clock.GetUtcNow();
|
var started = clock.GetUtcNow();
|
||||||
|
|
||||||
@@ -269,7 +273,7 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
{
|
{
|
||||||
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
|
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
|
||||||
|
|
||||||
ending = await PumpAsync(socket, target, counters, started, requestAborted).ConfigureAwait(false);
|
ending = await PumpAsync(socket, target, counters, started, revoked, requestAborted).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
RelayLog.SessionClosed(
|
RelayLog.SessionClosed(
|
||||||
@@ -338,13 +342,14 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
Stream target,
|
Stream target,
|
||||||
Counters counters,
|
Counters counters,
|
||||||
DateTimeOffset started,
|
DateTimeOffset started,
|
||||||
|
CancellationToken revoked,
|
||||||
CancellationToken requestAborted)
|
CancellationToken requestAborted)
|
||||||
{
|
{
|
||||||
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
|
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
|
||||||
|
|
||||||
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
|
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
|
||||||
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
|
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
|
||||||
var minding = MindAsync(counters, started, pump.Token);
|
var minding = MindAsync(counters, started, revoked, pump.Token);
|
||||||
|
|
||||||
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
|
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
|
||||||
var ending = await first.ConfigureAwait(false) ?? new Ending(
|
var ending = await first.ConfigureAwait(false) ?? new Ending(
|
||||||
@@ -512,7 +517,11 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
|
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<Ending?> MindAsync(Counters counters, DateTimeOffset started, CancellationToken cancellationToken)
|
private async Task<Ending?> MindAsync(
|
||||||
|
Counters counters,
|
||||||
|
DateTimeOffset started,
|
||||||
|
CancellationToken revoked,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var settings = options.Value;
|
var settings = options.Value;
|
||||||
var hardDeadline = started + settings.MaxSessionDuration;
|
var hardDeadline = started + settings.MaxSessionDuration;
|
||||||
@@ -527,6 +536,56 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
drainDeadline = now + settings.DrainTimeout;
|
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)
|
if (now >= hardDeadline)
|
||||||
{
|
{
|
||||||
return new Ending(
|
return new Ending(
|
||||||
@@ -540,40 +599,18 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
return ShuttingDown;
|
return ShuttingDown;
|
||||||
}
|
}
|
||||||
|
|
||||||
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
|
return now >= idleDeadline
|
||||||
|
? new Ending(
|
||||||
if (now >= idleDeadline)
|
|
||||||
{
|
|
||||||
return new Ending(
|
|
||||||
RelayCloseReason.IdleTimeout,
|
RelayCloseReason.IdleTimeout,
|
||||||
RelayProtocol.IdleTimeoutCloseCode,
|
RelayProtocol.IdleTimeoutCloseCode,
|
||||||
"This relay session carried nothing for the idle timeout.");
|
"This relay session carried nothing for the idle timeout.")
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
|
private static Ending AccessRevoked { get; } = new(
|
||||||
|
RelayCloseReason.AccessRevoked,
|
||||||
// Woken early by the start of a shutdown, so the drain is measured from when it began rather
|
RelayProtocol.AccessRevokedCloseCode,
|
||||||
// than from whenever the idle clock next came round.
|
"You no longer have access to this host.");
|
||||||
using var wake = drainDeadline is null
|
|
||||||
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.ApplicationStopping)
|
|
||||||
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// The shutdown began. Round again to start the drain.
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Ending ClientGone { get; } =
|
private static Ending ClientGone { get; } =
|
||||||
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
|
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
|
||||||
|
|||||||
@@ -91,4 +91,14 @@ internal static partial class RelayLog
|
|||||||
Level = LogLevel.Warning,
|
Level = LogLevel.Warning,
|
||||||
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
|
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
|
||||||
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
|
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);
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
{
|
{
|
||||||
private readonly Lock gate = new();
|
private readonly Lock gate = new();
|
||||||
private readonly Dictionary<Guid, int> perUser = [];
|
private readonly Dictionary<Guid, int> perUser = [];
|
||||||
|
private readonly HashSet<Lease> open = [];
|
||||||
private int total;
|
private int total;
|
||||||
|
|
||||||
/// <summary>How many sessions are open on this node.</summary>
|
/// <summary>How many sessions are open on this node.</summary>
|
||||||
@@ -55,7 +56,16 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
|
|
||||||
/// <summary>Admits a session, or answers null when a limit is met.</summary>
|
/// <summary>Admits a session, or answers null when a limit is met.</summary>
|
||||||
/// <returns>A lease that gives the place back when disposed.</returns>
|
/// <returns>A lease that gives the place back when disposed.</returns>
|
||||||
internal Lease? TryAdmit(Guid userId)
|
/// <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;
|
var settings = options.Value;
|
||||||
|
|
||||||
@@ -71,14 +81,18 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
perUser[userId] = held + 1;
|
perUser[userId] = held + 1;
|
||||||
total++;
|
total++;
|
||||||
|
|
||||||
return new Lease(this, userId);
|
var lease = new Lease(this, userId, vaultId);
|
||||||
|
open.Add(lease);
|
||||||
|
|
||||||
|
return lease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Release(Guid userId)
|
private void Release(Lease lease, Guid userId)
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
|
open.Remove(lease);
|
||||||
total--;
|
total--;
|
||||||
|
|
||||||
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
|
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
|
||||||
@@ -95,16 +109,41 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
|
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
|
||||||
internal sealed class Lease(RelaySessionRegistry registry, Guid userId) : IDisposable
|
internal sealed class Lease(RelaySessionRegistry registry, Guid userId, Guid vaultId) : IDisposable
|
||||||
{
|
{
|
||||||
|
private readonly CancellationTokenSource revoked = new();
|
||||||
|
|
||||||
private int released;
|
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 />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (Interlocked.Exchange(ref released, 1) == 0)
|
if (Interlocked.Exchange(ref released, 1) == 0)
|
||||||
{
|
{
|
||||||
registry.Release(userId);
|
registry.Release(this, UserId);
|
||||||
|
revoked.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ internal static class ItemKinds
|
|||||||
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
|
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
|
||||||
new HostGroupKind(), new TagKind(), new SnippetKind(),
|
new HostGroupKind(), new TagKind(), new SnippetKind(),
|
||||||
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
|
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
|
||||||
|
new SpelunkKeyKind(),
|
||||||
}.ToDictionary(kind => kind.WireType);
|
}.ToDictionary(kind => kind.WireType);
|
||||||
|
|
||||||
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
|
/// <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 />
|
/// <inheritdoc />
|
||||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
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;
|
||||||
|
}
|
||||||
@@ -27,4 +27,16 @@ internal static partial class SyncLog
|
|||||||
int appliedCount,
|
int appliedCount,
|
||||||
int operationCount,
|
int operationCount,
|
||||||
Guid vaultId);
|
Guid vaultId);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2003,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Collected {Count} expired tombstone(s) and superseded change-log entries.")]
|
||||||
|
internal static partial void Collected(ILogger logger, int count);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2004,
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Tombstone collection failed; it will run again at the next interval.")]
|
||||||
|
internal static partial void CollectionFailed(ILogger logger, Exception exception);
|
||||||
}
|
}
|
||||||
@@ -26,12 +26,59 @@ internal sealed class SyncService(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var afterSequence = 0L;
|
var afterSequence = 0L;
|
||||||
|
var issuedUnder = 0L;
|
||||||
if (!string.IsNullOrEmpty(request.Cursor)
|
if (!string.IsNullOrEmpty(request.Cursor)
|
||||||
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence))
|
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence, out issuedUnder))
|
||||||
{
|
{
|
||||||
throw new InvalidCursorException();
|
throw new InvalidCursorException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read before the changes, for the cursor this read hands out; see the check after it.
|
||||||
|
var horizonBefore = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var (changes, hasMore) = await ReadPageAsync(vault.Id, afterSequence, request, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// ◆ After the read, not before it. The collector deletes a tombstone's entries and raises the horizon
|
||||||
|
// in one transaction, so a read that already missed an entry is followed by a horizon that says so;
|
||||||
|
// checked first, the two could straddle a collection and the deletion would go unnoticed for good.
|
||||||
|
//
|
||||||
|
// A cursor behind the horizon is refused only if it was issued before it. One issued since belongs to
|
||||||
|
// a read from the beginning that started after the collection, and its early pages are behind the
|
||||||
|
// horizon because old, untouched items are; refusing those would send that read back to the start
|
||||||
|
// for ever. See SyncCursor.
|
||||||
|
var horizon = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (afterSequence > 0 && afterSequence < horizon && issuedUnder < horizon)
|
||||||
|
{
|
||||||
|
throw new InvalidCursorException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// When nothing came back the cursor must not move, or a concurrent write landing between
|
||||||
|
// this read and the next would be skipped forever.
|
||||||
|
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
|
||||||
|
|
||||||
|
return new SyncPullResponse(
|
||||||
|
Changes: hydrated,
|
||||||
|
// The horizon from before the read, never the one after: a collection landing between the two
|
||||||
|
// could have taken an entry this page would otherwise have carried, and a cursor claiming the
|
||||||
|
// later horizon would vouch for a client that never heard of it. The earlier one costs at most
|
||||||
|
// a restart.
|
||||||
|
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence, horizonBefore),
|
||||||
|
HasMore: hasMore,
|
||||||
|
ServerTime: clock.GetUtcNow(),
|
||||||
|
CurrentKeyGeneration: (uint)vault.KeyGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The changes after a position, one page of them, and whether there are more.</summary>
|
||||||
|
private async Task<(List<VaultChange> Changes, bool HasMore)> ReadPageAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
long afterSequence,
|
||||||
|
SyncPullRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
var limit = Math.Clamp(
|
var limit = Math.Clamp(
|
||||||
request.Limit ?? options.DefaultPullLimit,
|
request.Limit ?? options.DefaultPullLimit,
|
||||||
1,
|
1,
|
||||||
@@ -43,7 +90,7 @@ internal sealed class SyncService(
|
|||||||
|
|
||||||
// One extra row, so "is there more" needs no second query.
|
// One extra row, so "is there more" needs no second query.
|
||||||
var query = database.VaultChanges
|
var query = database.VaultChanges
|
||||||
.Where(c => c.VaultId == vault.Id && c.Sequence > afterSequence);
|
.Where(c => c.VaultId == vaultId && c.Sequence > afterSequence);
|
||||||
|
|
||||||
if (types is not null)
|
if (types is not null)
|
||||||
{
|
{
|
||||||
@@ -62,18 +109,7 @@ internal sealed class SyncService(
|
|||||||
changes.RemoveAt(changes.Count - 1);
|
changes.RemoveAt(changes.Count - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
|
return (changes, hasMore);
|
||||||
|
|
||||||
// When nothing came back the cursor must not move, or a concurrent write landing between
|
|
||||||
// this read and the next would be skipped forever.
|
|
||||||
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
|
|
||||||
|
|
||||||
return new SyncPullResponse(
|
|
||||||
Changes: hydrated,
|
|
||||||
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence),
|
|
||||||
HasMore: hasMore,
|
|
||||||
ServerTime: clock.GetUtcNow(),
|
|
||||||
CurrentKeyGeneration: (uint)vault.KeyGeneration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -100,9 +136,7 @@ internal sealed class SyncService(
|
|||||||
{
|
{
|
||||||
ValidateBatchLimits(request);
|
ValidateBatchLimits(request);
|
||||||
|
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
return await database.ExecuteFreshAsync([vault], async () =>
|
||||||
|
|
||||||
return await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
@@ -536,9 +570,21 @@ internal sealed class SyncService(
|
|||||||
|
|
||||||
/// <summary>Attaches current row state to change-log entries.</summary>
|
/// <summary>Attaches current row state to change-log entries.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
||||||
Vault vault,
|
Vault vault,
|
||||||
@@ -571,7 +617,10 @@ internal sealed class SyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. changes.Select(change => Describe(
|
return [.. changes
|
||||||
|
.Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
|
||||||
|
|| row.ChangeSequence <= change.Sequence)
|
||||||
|
.Select(change => Describe(
|
||||||
change,
|
change,
|
||||||
kinds.GetValueOrDefault(change.EntityType),
|
kinds.GetValueOrDefault(change.EntityType),
|
||||||
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
||||||
@@ -609,14 +658,30 @@ internal sealed class SyncService(
|
|||||||
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
|
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
|
||||||
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
|
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
|
||||||
|
|
||||||
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) =>
|
/// <remarks>
|
||||||
await database.VaultChanges
|
/// Never below the vault's horizon: collecting a tombstone can take the newest entries with it, and a
|
||||||
|
/// head reported lower than a cursor the client already holds would rewind it under the horizon and
|
||||||
|
/// cost a full resync for nothing.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var head = await database.VaultChanges
|
||||||
.Where(c => c.VaultId == vaultId)
|
.Where(c => c.VaultId == vaultId)
|
||||||
.OrderByDescending(c => c.Sequence)
|
.OrderByDescending(c => c.Sequence)
|
||||||
.Select(c => c.Sequence)
|
.Select(c => c.Sequence)
|
||||||
.FirstOrDefaultAsync(cancellationToken)
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Math.Max(head, await HorizonAsync(vaultId, cancellationToken).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The vault's horizon as the database holds it now, not as the request loaded it.</summary>
|
||||||
|
private Task<long> HorizonAsync(Guid vaultId, CancellationToken cancellationToken) =>
|
||||||
|
database.Vaults
|
||||||
|
.Where(v => v.Id == vaultId)
|
||||||
|
.Select(v => v.SyncHorizon)
|
||||||
|
.SingleAsync(cancellationToken);
|
||||||
|
|
||||||
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
|
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
|
||||||
new(
|
new(
|
||||||
operation.OperationId,
|
operation.OperationId,
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using DodoSSH.Api.Setup;
|
||||||
|
using DodoSSH.Domain;
|
||||||
|
using DodoSSH.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes tombstones past their retention, and change-log entries a later one has superseded.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ADR 0003 promised this and nothing did it: every deletion stayed a row for ever, and every edit stayed a
|
||||||
|
/// change-log entry for ever, so the log grew without bound and a client starting from nothing walked every
|
||||||
|
/// edit ever made to read the items that exist.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Superseded entries go at any time.</b> A pull returns entries after a cursor in order, so a client
|
||||||
|
/// before both an item's old entry and its new one reads the new one, and a client between them already has
|
||||||
|
/// the old one. Nothing reads history: a conflict resolves against the newest entry alone.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Tombstones go after the retention</b>, and taking them is the one change here a client can notice: a
|
||||||
|
/// machine that last synced before the deletion would never hear of it. So each vault records the highest
|
||||||
|
/// sequence collected from it — <see cref="Vault.SyncHorizon"/> — and a pull from a cursor below that is
|
||||||
|
/// refused. The client answers by starting over, and a pull from nothing removes what the server no longer
|
||||||
|
/// has; see the client's <c>SyncEngine</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Per vault under the same advisory lock a push takes, so a collection never interleaves with a write to
|
||||||
|
/// the vault — and the items are read again under it, so one restored since it was found is left alone.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class TombstoneCollector(
|
||||||
|
DodoDbContext database,
|
||||||
|
IOptions<SyncOptions> syncOptions,
|
||||||
|
TimeProvider clock,
|
||||||
|
ILogger<TombstoneCollector> logger)
|
||||||
|
{
|
||||||
|
/// <summary>Collects what is due, once.</summary>
|
||||||
|
/// <returns>How many tombstones and superseded entries went.</returns>
|
||||||
|
internal async Task<int> RunOnceAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var before = clock.GetUtcNow() - TimeSpan.FromDays(syncOptions.Value.TombstoneRetentionDays);
|
||||||
|
|
||||||
|
var collected = 0;
|
||||||
|
|
||||||
|
collected += await CollectAsync(db => db.Hosts, ChangeEntityType.SshHost, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.SshKeys, ChangeEntityType.SshKey, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.Credentials, ChangeEntityType.Credential, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.KnownHostKeys, ChangeEntityType.KnownHostKey, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.HostGroups, ChangeEntityType.HostGroup, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.Tags, ChangeEntityType.Tag, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.Snippets, ChangeEntityType.Snippet, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.ConnectionLog, ChangeEntityType.ConnectionLogEntry, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.ActivityLog, ChangeEntityType.ActivityLogEntry, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.ObjectStores, ChangeEntityType.ObjectStore, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.SpelunkKeys, ChangeEntityType.SpelunkKey, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
collected += await CompactAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
SyncLog.Collected(logger, collected);
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CollectAsync<T>(
|
||||||
|
Func<DodoDbContext, DbSet<T>> table,
|
||||||
|
ChangeEntityType type,
|
||||||
|
DateTimeOffset before,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where T : class, IVaultItem
|
||||||
|
{
|
||||||
|
var due = await table(database)
|
||||||
|
.Where(item => item.DeletedAtUtc != null && item.DeletedAtUtc < before)
|
||||||
|
.Select(item => new { item.VaultId, item.Id })
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var collected = 0;
|
||||||
|
|
||||||
|
foreach (var vault in due.GroupBy(item => item.VaultId))
|
||||||
|
{
|
||||||
|
var ids = vault.Select(item => item.Id).ToArray();
|
||||||
|
|
||||||
|
collected += await database.ExecuteFreshAsync(
|
||||||
|
[],
|
||||||
|
() => CollectFromVaultAsync(table, type, vault.Key, ids, before, cancellationToken))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CollectFromVaultAsync<T>(
|
||||||
|
Func<DodoDbContext, DbSet<T>> table,
|
||||||
|
ChangeEntityType type,
|
||||||
|
Guid vaultId,
|
||||||
|
Guid[] ids,
|
||||||
|
DateTimeOffset before,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where T : class, IVaultItem
|
||||||
|
{
|
||||||
|
var transaction = await database.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var _ = transaction.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await database.Database
|
||||||
|
.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))", cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Read again under the lock: an item restored since it was found is no longer due.
|
||||||
|
var still = await table(database)
|
||||||
|
.Where(item => item.VaultId == vaultId
|
||||||
|
&& ids.Contains(item.Id)
|
||||||
|
&& item.DeletedAtUtc != null
|
||||||
|
&& item.DeletedAtUtc < before)
|
||||||
|
.Select(item => new { item.Id, item.ChangeSequence })
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (still.Count == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var going = still.Select(item => item.Id).ToArray();
|
||||||
|
var horizon = still.Max(item => item.ChangeSequence);
|
||||||
|
|
||||||
|
await table(database)
|
||||||
|
.Where(item => item.VaultId == vaultId && going.Contains(item.Id))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await database.VaultChanges
|
||||||
|
.Where(change => change.VaultId == vaultId && change.EntityType == type && going.Contains(change.EntityId))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await database.Vaults
|
||||||
|
.Where(v => v.Id == vaultId && v.SyncHorizon < horizon)
|
||||||
|
.ExecuteUpdateAsync(set => set.SetProperty(v => v.SyncHorizon, horizon), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return still.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes every change-log entry a later entry for the same item has superseded.</summary>
|
||||||
|
private Task<int> CompactAsync(CancellationToken cancellationToken) =>
|
||||||
|
database.Database.ExecuteSqlRawAsync(
|
||||||
|
"DELETE FROM " + DodoDbContext.SchemaName + ".sync_change AS older "
|
||||||
|
+ "USING " + DodoDbContext.SchemaName + ".sync_change AS newer "
|
||||||
|
+ "WHERE newer.vault_id = older.vault_id "
|
||||||
|
+ "AND newer.entity_type = older.entity_type "
|
||||||
|
+ "AND newer.entity_id = older.entity_id "
|
||||||
|
+ "AND newer.sequence > older.sequence",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs the <see cref="TombstoneCollector"/> on a schedule, in a scope of its own each time.</summary>
|
||||||
|
internal sealed class TombstoneCollectionService(
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
IOptions<SyncOptions> syncOptions,
|
||||||
|
TimeProvider clock,
|
||||||
|
ILogger<TombstoneCollectionService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var interval = TimeSpan.FromMinutes(syncOptions.Value.TombstoneCollectionIntervalMinutes);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(interval, clock, stoppingToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var scope = scopes.CreateAsyncScope();
|
||||||
|
await using var _ = scope.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await scope.ServiceProvider.GetRequiredService<TombstoneCollector>()
|
||||||
|
.RunOnceAsync(stoppingToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// The next run tries again; nothing collected late is harmed by waiting.
|
||||||
|
SyncLog.CollectionFailed(logger, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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
|
// from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
|
||||||
// re-checks it, unlike the transfer below, so this line is the whole of the guard.
|
// checks it again, because deleting a team's last vault archives it too.
|
||||||
if (!access.IsOwner)
|
if (!access.IsOwner)
|
||||||
{
|
{
|
||||||
return Problems.Coded(
|
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 System.Globalization;
|
||||||
using DodoSSH.Api.Features.Events;
|
using DodoSSH.Api.Features.Events;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
using DodoSSH.Infrastructure;
|
using DodoSSH.Infrastructure;
|
||||||
@@ -244,8 +245,27 @@ internal sealed class TeamService(
|
|||||||
Team team,
|
Team team,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(actor);
|
||||||
ArgumentNullException.ThrowIfNull(team);
|
ArgumentNullException.ThrowIfNull(team);
|
||||||
|
|
||||||
|
// 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 InvalidOperationException("Only the owner of a team can archive it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
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);
|
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (vaultCount > 0)
|
if (vaultCount > 0)
|
||||||
@@ -256,16 +276,6 @@ internal sealed class TeamService(
|
|||||||
$"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."));
|
$"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 now = clock.GetUtcNow();
|
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
|
||||||
|
|
||||||
var archived = await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
|
||||||
var transaction = await database.Database
|
|
||||||
.BeginTransactionAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
await using var _ = transaction.ConfigureAwait(false);
|
|
||||||
|
|
||||||
var memberships = await database.TeamMemberships
|
var memberships = await database.TeamMemberships
|
||||||
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
@@ -335,9 +345,7 @@ internal sealed class TeamService(
|
|||||||
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
|
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
await database.ExecuteFreshAsync([incoming, outgoing], async () =>
|
||||||
|
|
||||||
await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
@@ -716,9 +724,7 @@ internal sealed class TeamService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var now = clock.GetUtcNow();
|
var now = clock.GetUtcNow();
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
var revoked = await database.ExecuteFreshAsync([membership], async () =>
|
||||||
|
|
||||||
var revoked = await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
|
|||||||
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
|
|||||||
{
|
{
|
||||||
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
|
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);
|
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using DodoSSH.Api.Features.Events;
|
using DodoSSH.Api.Features.Events;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
using DodoSSH.Infrastructure;
|
using DodoSSH.Infrastructure;
|
||||||
@@ -76,6 +77,40 @@ internal sealed class VaultGrantService(
|
|||||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
"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);
|
||||||
|
|
||||||
|
return new VaultSummary(
|
||||||
|
VaultId: request.VaultId,
|
||||||
|
Name: name,
|
||||||
|
IsPersonal: false,
|
||||||
|
TeamId: team.Id,
|
||||||
|
KeyGeneration: 1,
|
||||||
|
Permissions: 0,
|
||||||
|
WrappedVaultKey: request.WrappedVaultKey,
|
||||||
|
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());
|
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -90,17 +125,20 @@ internal sealed class VaultGrantService(
|
|||||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||||
}
|
}
|
||||||
|
|
||||||
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
});
|
||||||
|
|
||||||
return new VaultSummary(
|
/// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
|
||||||
VaultId: request.VaultId,
|
private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
|
||||||
Name: name,
|
{
|
||||||
IsPersonal: false,
|
var live = await database.Teams
|
||||||
TeamId: team.Id,
|
.AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
|
||||||
KeyGeneration: 1,
|
.ConfigureAwait(false);
|
||||||
Permissions: 0,
|
|
||||||
WrappedVaultKey: request.WrappedVaultKey,
|
if (!live)
|
||||||
RekeyRequired: false);
|
{
|
||||||
|
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>
|
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
||||||
|
|||||||
@@ -41,13 +41,16 @@ builder.Services.AddScoped<VaultGrantService>();
|
|||||||
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
||||||
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||||
|
|
||||||
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
|
// A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
|
||||||
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
|
// whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
|
||||||
// write paths that announce a change need only the two methods that announce one, and should not gain
|
// that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
|
||||||
// a reference to connection management to get them.
|
// vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
|
||||||
builder.Services.AddSingleton<VaultEventHub>();
|
builder.Services.AddSingleton<VaultEventHub>();
|
||||||
builder.Services.AddSingleton<IVaultEventPublisher>(
|
builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
|
||||||
provider => provider.GetRequiredService<VaultEventHub>());
|
|
||||||
|
// ADR 0003's tombstone collection, and the compaction of the change log that goes with it.
|
||||||
|
builder.Services.AddScoped<TombstoneCollector>();
|
||||||
|
builder.Services.AddHostedService<TombstoneCollectionService>();
|
||||||
|
|
||||||
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
|
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
|
||||||
builder.Services.AddDodoRelay();
|
builder.Services.AddDodoRelay();
|
||||||
@@ -55,6 +58,7 @@ builder.Services.AddDodoRelay();
|
|||||||
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
|
// 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.
|
// DbContext, and a singleton would capture one for the lifetime of the process.
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
||||||
|
builder.Services.AddScoped<IAuthorizationHandler, ActiveAccountHandler>();
|
||||||
|
|
||||||
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
||||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ internal static class Auth
|
|||||||
|
|
||||||
private static void AddDodoPolicies(AuthorizationOptions options)
|
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.
|
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
|
||||||
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
||||||
@@ -72,7 +79,7 @@ internal static class Auth
|
|||||||
EnrolledPolicy,
|
EnrolledPolicy,
|
||||||
policy => policy
|
policy => policy
|
||||||
.RequireAuthenticatedUser()
|
.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.
|
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
||||||
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
||||||
|
|||||||
@@ -289,6 +289,10 @@ public sealed class SyncOptions
|
|||||||
[Range(1, 3650)]
|
[Range(1, 3650)]
|
||||||
public int TombstoneRetentionDays { get; set; } = 90;
|
public int TombstoneRetentionDays { get; set; } = 90;
|
||||||
|
|
||||||
|
/// <summary>How often the tombstone collector runs, in minutes.</summary>
|
||||||
|
[Range(1, 7 * 24 * 60)]
|
||||||
|
public int TombstoneCollectionIntervalMinutes { get; set; } = 6 * 60;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Key used to sign sync cursors, base64.
|
/// Key used to sign sync cursors, base64.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -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 DodoSSH.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -29,14 +30,18 @@ internal static class Persistence
|
|||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
||||||
|
|
||||||
services.AddDbContext<DodoDbContext>(options => options
|
services.AddDbContext<DodoDbContext>((provider, options) => options
|
||||||
.UseNpgsql(connectionString, npgsql => npgsql
|
.UseNpgsql(connectionString, npgsql => npgsql
|
||||||
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
||||||
// Retry on transient faults only. Note this is safe here because writes go
|
// Retry on transient faults only. A write transaction replayed by the strategy runs on
|
||||||
// through explicitly-managed transactions rather than relying on the execution
|
// the same context, which still holds what the failed attempt tracked — every such
|
||||||
// strategy to replay ambient ones.
|
// transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
|
||||||
|
// the database instead.
|
||||||
.EnableRetryOnFailure(3))
|
.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()
|
services.AddHealthChecks()
|
||||||
.AddCheck<DatabaseHealthCheck>(
|
.AddCheck<DatabaseHealthCheck>(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ internal static class RelaySetup
|
|||||||
// the network seam holds no state.
|
// the network seam holds no state.
|
||||||
services.AddSingleton<RelayTicketCodec>();
|
services.AddSingleton<RelayTicketCodec>();
|
||||||
services.AddSingleton<RelaySessionRegistry>();
|
services.AddSingleton<RelaySessionRegistry>();
|
||||||
|
services.AddSingleton<RelayAccessWatch>();
|
||||||
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
|
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
|
||||||
|
|
||||||
// The application name isolates this deployment's payloads from any other ASP.NET application
|
// The application name isolates this deployment's payloads from any other ASP.NET application
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
var keepAlive = new SessionKeepAlive(
|
var keepAlive = new SessionKeepAlive(
|
||||||
workspace,
|
workspace,
|
||||||
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
|
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
|
// 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.
|
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
|
||||||
@@ -148,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.
|
// in the first place, which was precisely the promise this whole arrangement exists to keep.
|
||||||
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
|
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();
|
keepAlive.Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
using global::Android.OS;
|
||||||
using global::Android.Views;
|
using global::Android.Views;
|
||||||
|
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
using DodoSSH.Client.Shell.Keyboard;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Android.Platform;
|
namespace DodoSSH.Client.Android.Platform;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
|
/// Takes the keyboard away from the terminal's <c>WebView</c>, reports the taps that should bring it back,
|
||||||
/// back.
|
/// and turns a long press and drag into arrow keys.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -36,6 +41,23 @@ namespace DodoSSH.Client.Android.Platform;
|
|||||||
/// keyboard over the very output being scrolled to.
|
/// keyboard over the very output being scrolled to.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// <b>A long press and a drag is the cursor gesture, and scrolling is still everything else.</b> There is no
|
||||||
|
/// mode to switch: a finger that moves straight away is a scroll, exactly as before, and the page gets every
|
||||||
|
/// event of it. A finger held still past <see cref="CursorGestureDelay"/> becomes a joystick for the arrow
|
||||||
|
/// keys — see <see cref="ArrowGesture"/> for the speeds — and from that moment the rest of the touch is this
|
||||||
|
/// class's: the <c>WebView</c> is sent a cancel, so it lets go of whatever it thought was starting, and is
|
||||||
|
/// not shown another event until the finger lifts. A haptic tick says the gesture has begun, because nothing
|
||||||
|
/// can be drawn over the terminal to say it instead.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The delay is a little short of the platform's long-press timeout, and that is the point of it. Chromium
|
||||||
|
/// starts its own long press — selection, and a <c>contextmenu</c> event the page answers with a paste — at
|
||||||
|
/// the platform timeout, so taking the touch away first is what keeps a held finger from pasting the
|
||||||
|
/// clipboard into a shell. Both timers run on the main looper and this one is posted first and shorter, so
|
||||||
|
/// it wins without a race. With the gesture switched off nothing is taken, and a long press does what it
|
||||||
|
/// did before.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
|
/// 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
|
/// <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
|
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
|
||||||
@@ -52,17 +74,26 @@ internal static class RendererFocus
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static WeakReference<global::Android.Webkit.WebView>? configured;
|
private static WeakReference<global::Android.Webkit.WebView>? configured;
|
||||||
|
|
||||||
|
/// <summary>How long a finger has to stay still for the cursor gesture, as a share of the long-press timeout.</summary>
|
||||||
|
private const double CursorGestureDelay = 0.8;
|
||||||
|
|
||||||
|
/// <summary>How often a held gesture is asked whether another arrow has come due — about one frame.</summary>
|
||||||
|
private const long TickMs = 16;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
|
/// Makes the renderer unable to hold the keyboard, calls <paramref name="onTapped"/> when somebody taps
|
||||||
/// taps it.
|
/// it, and <paramref name="onArrows"/> while somebody long-presses and drags it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="onTapped">A tap: bring the keyboard back.</param>
|
||||||
|
/// <param name="gesturesEnabled">Asked at each press, so the setting applies from the next touch.</param>
|
||||||
|
/// <param name="onArrows">How many arrows to send, and which.</param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
|
/// 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
|
/// <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
|
/// 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>).
|
/// elsewhere (see <c>docs/android-port.md</c>).
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static void Suppress(Action onTapped)
|
public static void Suppress(Action onTapped, Func<bool> gesturesEnabled, Action<ArrowDirection, int> onArrows)
|
||||||
{
|
{
|
||||||
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||||
{
|
{
|
||||||
@@ -83,57 +114,246 @@ internal static class RendererFocus
|
|||||||
webView.FocusableInTouchMode = false;
|
webView.FocusableInTouchMode = false;
|
||||||
webView.ClearFocus();
|
webView.ClearFocus();
|
||||||
|
|
||||||
WatchForTaps(webView, onTapped);
|
WatchTouches(webView, onTapped, gesturesEnabled, onArrows);
|
||||||
|
|
||||||
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
|
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
|
/// Calls <paramref name="onTapped"/> for a tap, runs the cursor gesture for a long press, and stays out of
|
||||||
|
/// the way of everything else.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A tap is a press and a release that did not travel further than the platform's own
|
/// 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:
|
/// <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.
|
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to. The
|
||||||
|
/// same slop is what cancels a pending long press, so a scroll can never turn into the gesture halfway.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
|
private static void WatchTouches(
|
||||||
|
global::Android.Webkit.WebView webView,
|
||||||
|
Action onTapped,
|
||||||
|
Func<bool> gesturesEnabled,
|
||||||
|
Action<ArrowDirection, int> onArrows)
|
||||||
{
|
{
|
||||||
// The fallback is the platform's own historical default, for a context that has somehow gone.
|
var touch = new TouchState(webView, gesturesEnabled, onArrows);
|
||||||
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
|
|
||||||
var downX = 0f;
|
|
||||||
var downY = 0f;
|
|
||||||
var moved = false;
|
|
||||||
|
|
||||||
webView.Touch += (_, e) =>
|
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)
|
if (e.Event is not { } motion)
|
||||||
{
|
{
|
||||||
|
e.Handled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (motion.Action)
|
// Observed and passed on, except while the cursor gesture holds the finger: then the page must not
|
||||||
|
// see it scroll.
|
||||||
|
e.Handled = touch.Handle(motion, onTapped);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One finger's worth of touch on the terminal, from press to release.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A class rather than locals captured by the handler, because two timers — the long press and the
|
||||||
|
/// repeat tick — read and write the same state from outside the touch callback.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Avalonia's <see cref="DispatcherTimer"/> rather than <c>View.PostDelayed</c>. On this head the
|
||||||
|
/// dispatcher is the main looper, so the timers land on the same thread and in the same queue as the
|
||||||
|
/// touches and as Chromium's own long press; and a dispatcher timer is a plain managed object, where a
|
||||||
|
/// posted <c>Runnable</c> is a Java peer that would want disposing for the life of a view that has none.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class TouchState
|
||||||
|
{
|
||||||
|
private readonly global::Android.Webkit.WebView webView;
|
||||||
|
private readonly Func<bool> gesturesEnabled;
|
||||||
|
private readonly Action<ArrowDirection, int> onArrows;
|
||||||
|
private readonly ArrowGesture gesture = new();
|
||||||
|
private readonly int slop;
|
||||||
|
private readonly float density;
|
||||||
|
private readonly DispatcherTimer longPress;
|
||||||
|
private readonly DispatcherTimer tick;
|
||||||
|
|
||||||
|
private float downX;
|
||||||
|
private float downY;
|
||||||
|
private float lastX;
|
||||||
|
private float lastY;
|
||||||
|
private long downTime;
|
||||||
|
private bool moved;
|
||||||
|
private bool steering;
|
||||||
|
private int lastSpeed;
|
||||||
|
|
||||||
|
internal TouchState(
|
||||||
|
global::Android.Webkit.WebView webView,
|
||||||
|
Func<bool> gesturesEnabled,
|
||||||
|
Action<ArrowDirection, int> onArrows)
|
||||||
|
{
|
||||||
|
this.webView = webView;
|
||||||
|
this.gesturesEnabled = gesturesEnabled;
|
||||||
|
this.onArrows = onArrows;
|
||||||
|
|
||||||
|
// The fallbacks are the platform's own historical defaults, for a context that has somehow gone.
|
||||||
|
slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
|
||||||
|
density = webView.Resources?.DisplayMetrics?.Density is > 0 and var d ? d : 1;
|
||||||
|
|
||||||
|
longPress = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(ViewConfiguration.LongPressTimeout * CursorGestureDelay),
|
||||||
|
};
|
||||||
|
longPress.Tick += (_, _) => BeginSteering();
|
||||||
|
|
||||||
|
tick = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(TickMs) };
|
||||||
|
tick.Tick += (_, _) => Tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <returns>Whether the event is consumed rather than passed on to the page.</returns>
|
||||||
|
internal bool Handle(MotionEvent motion, Action onTapped)
|
||||||
|
{
|
||||||
|
switch (motion.ActionMasked)
|
||||||
{
|
{
|
||||||
case MotionEventActions.Down:
|
case MotionEventActions.Down:
|
||||||
downX = motion.GetX();
|
Press(motion);
|
||||||
downY = motion.GetY();
|
return false;
|
||||||
moved = false;
|
|
||||||
|
|
||||||
break;
|
case MotionEventActions.PointerDown:
|
||||||
|
// A second finger is a pinch or a two-finger scroll, never the cursor gesture.
|
||||||
|
longPress.Stop();
|
||||||
|
moved = true;
|
||||||
|
return steering;
|
||||||
|
|
||||||
|
case MotionEventActions.Move when steering:
|
||||||
|
Steer(motion.GetX(), motion.GetY());
|
||||||
|
return true;
|
||||||
|
|
||||||
case MotionEventActions.Move:
|
case MotionEventActions.Move:
|
||||||
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
|
Travel(motion.GetX(), motion.GetY());
|
||||||
|
return false;
|
||||||
|
|
||||||
break;
|
case MotionEventActions.Up or MotionEventActions.Cancel:
|
||||||
|
return Release(motion.ActionMasked is MotionEventActions.Up, onTapped);
|
||||||
|
|
||||||
case MotionEventActions.Up when !moved:
|
default:
|
||||||
|
return steering;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Press(MotionEvent motion)
|
||||||
|
{
|
||||||
|
downX = lastX = motion.GetX();
|
||||||
|
downY = lastY = motion.GetY();
|
||||||
|
downTime = motion.DownTime;
|
||||||
|
moved = false;
|
||||||
|
steering = false;
|
||||||
|
|
||||||
|
longPress.Stop();
|
||||||
|
|
||||||
|
if (gesturesEnabled())
|
||||||
|
{
|
||||||
|
longPress.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A finger moving before the long press: past the slop it is a scroll, and stays one.</summary>
|
||||||
|
private void Travel(float x, float y)
|
||||||
|
{
|
||||||
|
lastX = x;
|
||||||
|
lastY = y;
|
||||||
|
|
||||||
|
if (!moved && (Math.Abs(x - downX) > slop || Math.Abs(y - downY) > slop))
|
||||||
|
{
|
||||||
|
moved = true;
|
||||||
|
longPress.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <returns>Whether the release is consumed — it is, when it ends the cursor gesture.</returns>
|
||||||
|
private bool Release(bool lifted, Action onTapped)
|
||||||
|
{
|
||||||
|
longPress.Stop();
|
||||||
|
|
||||||
|
if (steering)
|
||||||
|
{
|
||||||
|
EndSteering();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lifted && !moved)
|
||||||
|
{
|
||||||
onTapped();
|
onTapped();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The finger has been held still long enough: take the touch and start steering.</summary>
|
||||||
|
private void BeginSteering()
|
||||||
|
{
|
||||||
|
longPress.Stop();
|
||||||
|
|
||||||
|
steering = true;
|
||||||
|
lastSpeed = 0;
|
||||||
|
gesture.Reset();
|
||||||
|
|
||||||
|
// The page has seen a press and nothing since; a cancel tells it that press is over, so neither
|
||||||
|
// its scroller nor its own long press goes on to do anything with it. Parents are asked not to
|
||||||
|
// intercept either — the gesture now owns the finger until it lifts.
|
||||||
|
var now = SystemClock.UptimeMillis();
|
||||||
|
|
||||||
|
using (var cancel = MotionEvent.Obtain(downTime, now, MotionEventActions.Cancel, lastX, lastY, MetaKeyStates.None))
|
||||||
|
{
|
||||||
|
if (cancel is not null)
|
||||||
|
{
|
||||||
|
webView.OnTouchEvent(cancel);
|
||||||
|
cancel.Recycle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.Parent?.RequestDisallowInterceptTouchEvent(true);
|
||||||
|
webView.PerformHapticFeedback(FeedbackConstants.LongPress);
|
||||||
|
|
||||||
|
tick.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Steer(float x, float y)
|
||||||
|
{
|
||||||
|
var count = gesture.Move((x - downX) / density, (y - downY) / density, SystemClock.UptimeMillis());
|
||||||
|
|
||||||
|
Emit(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The repeat clock: whatever has come due since the last frame.</summary>
|
||||||
|
private void Tick()
|
||||||
|
{
|
||||||
|
if (steering)
|
||||||
|
{
|
||||||
|
Emit(gesture.Advance(SystemClock.UptimeMillis()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Emit(int count)
|
||||||
|
{
|
||||||
|
if (count > 0)
|
||||||
|
{
|
||||||
|
onArrows(gesture.Direction, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tick under the thumb each time the speed shifts, which is the only way to feel the three
|
||||||
|
// levels without looking: the terminal under the finger cannot draw anything to show them.
|
||||||
|
if (gesture.Speed > lastSpeed && lastSpeed > 0)
|
||||||
|
{
|
||||||
|
webView.PerformHapticFeedback(FeedbackConstants.ClockTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSpeed = gesture.Speed;
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
private void EndSteering()
|
||||||
|
{
|
||||||
|
steering = false;
|
||||||
|
tick.Stop();
|
||||||
|
gesture.Reset();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
|
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ internal sealed class SessionForegroundService : Service
|
|||||||
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
||||||
/// <param name="activeTransfers">Transfers still moving bytes.</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="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>
|
/// <remarks>
|
||||||
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
|
/// 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
|
/// 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
|
/// 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.
|
/// start even over a service that looks like it is running; the inline remark below is why.
|
||||||
/// </remarks>
|
/// </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();
|
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
|
// 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.
|
// 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
|
// 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
|
// 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));
|
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)
|
if (liveSessions > 0)
|
||||||
{
|
{
|
||||||
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
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)
|
if (holdsFileSession)
|
||||||
{
|
{
|
||||||
parts.Add("Files connected");
|
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
|
/// 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.
|
/// gap closed, read the same way the other two facts are: asked, not cached.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
internal sealed class SessionKeepAlive : IDisposable
|
internal sealed class SessionKeepAlive : IDisposable
|
||||||
{
|
{
|
||||||
private readonly TerminalWorkspace workspace;
|
private readonly TerminalWorkspace workspace;
|
||||||
private readonly Func<int> activeTransfers;
|
private readonly Func<int> activeTransfers;
|
||||||
private readonly Func<bool> holdsFileSession;
|
private readonly Func<bool> holdsFileSession;
|
||||||
|
private readonly Func<int> listeningForwards;
|
||||||
|
|
||||||
/// <param name="workspace">The live shells.</param>
|
/// <param name="workspace">The live shells.</param>
|
||||||
/// <param name="activeTransfers">
|
/// <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-
|
/// 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>.
|
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
|
||||||
/// </param>
|
/// </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.workspace = workspace;
|
||||||
this.activeTransfers = activeTransfers;
|
this.activeTransfers = activeTransfers;
|
||||||
this.holdsFileSession = holdsFileSession;
|
this.holdsFileSession = holdsFileSession;
|
||||||
|
this.listeningForwards = listeningForwards;
|
||||||
|
|
||||||
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
|
// 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
|
// 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>
|
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
|
/// Called after anything that could change any of the facts — opening a shell, closing a tab, a transfer
|
||||||
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
|
/// 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,
|
/// 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,
|
/// 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.
|
/// and Android treats all of those as no-ops.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public void Refresh() =>
|
public void Refresh() =>
|
||||||
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
|
SessionForegroundService.Reconcile(
|
||||||
|
workspace.LiveSessionCount, activeTransfers(), holdsFileSession(), listeningForwards());
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
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
|
// 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.
|
// 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();
|
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
|
||||||
|
|||||||
@@ -1002,7 +1002,8 @@
|
|||||||
FormatString="0" ShowButtonSpinner="False" MinHeight="44"
|
FormatString="0" ShowButtonSpinner="False" MinHeight="44"
|
||||||
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
||||||
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
|
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
|
||||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
|
PlaceholderText="{Binding EditorUsernamePlaceholder}"
|
||||||
|
IsEnabled="{Binding EditorAsksForLogin}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
|
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
|
||||||
@@ -1016,6 +1017,7 @@
|
|||||||
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
|
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
|
||||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||||
|
IsEnabled="{Binding EditorAsksForLogin}"
|
||||||
HorizontalAlignment="Stretch" MinHeight="44">
|
HorizontalAlignment="Stretch" MinHeight="44">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||||
@@ -1028,6 +1030,9 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</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
|
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
|
||||||
@@ -1212,6 +1217,41 @@
|
|||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
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." />
|
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
|
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
||||||
own rather than relying on the one the list behind it draws at the top of HOSTS (see this file's
|
own rather than relying on the one the list behind it draws at the top of HOSTS (see this file's
|
||||||
|
|||||||
@@ -189,14 +189,18 @@
|
|||||||
◆ Port forwarding, stated rather than drawn.
|
◆ 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
|
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
|
rules with toggles and byte counters. Forwarding is built and runs here as on the desktop — a host's
|
||||||
offers OpenShellAsync and nothing else — so every switch on that screen would be a control with no
|
forwards start with every terminal opened to it — but it is not a destination, so it has no row. A
|
||||||
effect and every counter a number with no source.
|
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:
|
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
|
a place somebody looks for a feature should say where it went. See docs/design-import-gaps.md.
|
||||||
intended. SyncEntityType.PortForward = 9 is reserved on the wire and unused. See
|
|
||||||
docs/design-import-gaps.md.
|
|
||||||
-->
|
-->
|
||||||
<Border Classes="card" Margin="6,16,6,0">
|
<Border Classes="card" Margin="6,16,6,0">
|
||||||
<StackPanel Spacing="7">
|
<StackPanel Spacing="7">
|
||||||
@@ -206,11 +210,11 @@
|
|||||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||||
Foreground="{StaticResource TextDim}" Text="Port forwarding" />
|
Foreground="{StaticResource TextDim}" Text="Port forwarding" />
|
||||||
<Border Classes="tag outline" VerticalAlignment="Center">
|
<Border Classes="tag outline" VerticalAlignment="Center">
|
||||||
<TextBlock Text="NOT BUILT" />
|
<TextBlock Text="IN THE TERMINAL" />
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Classes="body"
|
<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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -579,7 +579,7 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The connect menu is a second such guard, and it matters more than the first.</b> A terminal now
|
/// <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
|
/// 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
|
/// 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.
|
/// menu would take two, neither of them the one being looked at.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -598,13 +598,12 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The connect menu, which is raised from the terminal's own bar and is the topmost thing the phone
|
// The connect menu or the forwards sheet, which are raised from the terminal's own bar and are the
|
||||||
// draws while it is up. Ahead of the editors below because it is nearer, and ahead of leaving the
|
// topmost thing the phone draws while one is up. Ahead of the editors below because they are nearer,
|
||||||
// terminal because a gesture that dismissed the surface underneath a menu would close two things at
|
// and ahead of leaving the terminal because a gesture that dismissed the surface underneath a sheet
|
||||||
// once — and the one the user was looking at would not be either of them.
|
// would close two things at once — and the one the user was looking at would not be either of them.
|
||||||
if (current.IsConnectSheetOpen)
|
if (TryCloseATerminalSheet(current))
|
||||||
{
|
{
|
||||||
current.CloseConnectSheetCommand.Execute(null);
|
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -647,6 +646,50 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lowers whatever is up over the terminal: the customise sheet, the quick-access keyboard, the connect
|
||||||
|
/// menu or the forwards sheet.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Whether one was closed, and so whether back has been spent.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The three sheets are raised from the terminal's bar or its keys and the scrim under each covers both,
|
||||||
|
/// so at most one is ever open. The quick-access keyboard comes second because it is the one that can be
|
||||||
|
/// up with nothing over it — opening the customise sheet lowers it, so the two never compete either.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool TryCloseATerminalSheet(MainWindowViewModel current)
|
||||||
|
{
|
||||||
|
// The customise sheet, raised from the quick-access keyboard. DONE is the only other way off it, and
|
||||||
|
// every change on it has already been saved, so back loses nothing.
|
||||||
|
if (current.Keyboard.IsCustomizing)
|
||||||
|
{
|
||||||
|
current.Keyboard.CloseCustomizeCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The quick-access keyboard stands where the software keyboard stands, and back puts the software
|
||||||
|
// keyboard away without leaving the terminal — so it does the same for this. Only on the terminal
|
||||||
|
// surface: the flag outlives a trip to another screen, where there is no panel to lower.
|
||||||
|
if (current.Keyboard.IsQuickAccessOpen && current.IsTerminalShowing)
|
||||||
|
{
|
||||||
|
current.Keyboard.CloseQuickAccess();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.IsConnectSheetOpen)
|
||||||
|
{
|
||||||
|
current.CloseConnectSheetCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.IsForwardsSheetOpen)
|
||||||
|
{
|
||||||
|
current.CloseForwardsSheetCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lowers whatever the hosts screen has raised over its list, topmost first.
|
/// Lowers whatever the hosts screen has raised over its list, topmost first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
xmlns:kb="using:DodoSSH.Client.Shell.Keyboard"
|
||||||
|
xmlns:views="using:DodoSSH.Client.Android.Views"
|
||||||
|
x:Class="DodoSSH.Client.Android.Views.QuickAccessKeyboard"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
Background="{StaticResource DeepChrome}">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The quick-access keyboard: every key, snippet and command from history, and the terminal's appearance,
|
||||||
|
one tap from the prompt.
|
||||||
|
|
||||||
|
One control in two places. On a phone it takes the software keyboard's place under the terminal, at the
|
||||||
|
keyboard's own height so the bar above it does not move; on a wide screen it is a panel beside the
|
||||||
|
terminal that stays up while the software keyboard comes and goes. Same tabs, same rows, same view model
|
||||||
|
either way — see TerminalScreen for which one is drawn when.
|
||||||
|
|
||||||
|
It reopens to the tab last used, which is a setting rather than a field on this control: the tab is the
|
||||||
|
one somebody reaches for on this device, and the next launch should know it too. See
|
||||||
|
TerminalKeyboardViewModel.SelectedTab.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<UserControl.Styles>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ NOTHING HERE TAKES FOCUS, FOR THE REASON THE BAR'S KEYS DO NOT.
|
||||||
|
|
||||||
|
This panel is an extension of the keyboard, not a place for the keyboard to go. On a wide screen the
|
||||||
|
software keyboard can be up at the same time, and an ordinary button would take Avalonia's focus off
|
||||||
|
TerminalInputSurface on tap — tearing down the input connection and dropping the keyboard every time a
|
||||||
|
key in this panel was used. On a phone the keyboard is down while this is up, but the focus it would take
|
||||||
|
is the focus that brings the keyboard straight back when the terminal is tapped. One rule for the whole
|
||||||
|
control, so a button added to it later cannot forget.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button">
|
||||||
|
<Setter Property="Focusable" Value="False" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- The tab strip's buttons: an icon square, lit when it is the tab showing. -->
|
||||||
|
<Style Selector="Button.qtab">
|
||||||
|
<Setter Property="Width" Value="48" />
|
||||||
|
<Setter Property="Height" Value="40" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||||
|
<Setter Property="FontSize" Value="20" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.qtab:pressed /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.qtab.selected">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.qtab.selected /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentSoft}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- A group's caption over its keys. -->
|
||||||
|
<Style Selector="TextBlock.groupcaption">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
<Setter Property="FontSize" Value="9.5" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Margin" Value="4,10,0,4" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Keys in a panel are a little shorter than the bar's: there are many more of them, all reachable. -->
|
||||||
|
<Style Selector="Button.hotkey.panelkey">
|
||||||
|
<Setter Property="Height" Value="38" />
|
||||||
|
<Setter Property="MinHeight" Value="38" />
|
||||||
|
<Setter Property="Margin" Value="3" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<!-- ============ the tabs ============ -->
|
||||||
|
<!--
|
||||||
|
Icons rather than words, as the keys tab's own grid is: four words at 360dp leave no room for the
|
||||||
|
keyboard button at the end, and each glyph is the one every phone uses for the thing — a grid of keys,
|
||||||
|
braces for snippets, a clock for history, a palette for appearance. Each carries its name for
|
||||||
|
accessibility, which is where a word belongs when there is no room to print one.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="8,6,8,2">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4">
|
||||||
|
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsKeysTab}" Content=""
|
||||||
|
AutomationProperties.Name="Keys"
|
||||||
|
Command="{Binding Keyboard.SelectTabCommand}"
|
||||||
|
CommandParameter="{x:Static vm:QuickAccessTab.Keys}" />
|
||||||
|
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsSnippetsTab}" Content=""
|
||||||
|
AutomationProperties.Name="Snippets"
|
||||||
|
Command="{Binding Keyboard.SelectTabCommand}"
|
||||||
|
CommandParameter="{x:Static vm:QuickAccessTab.Snippets}" />
|
||||||
|
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsHistoryTab}" Content=""
|
||||||
|
AutomationProperties.Name="History"
|
||||||
|
Command="{Binding Keyboard.SelectTabCommand}"
|
||||||
|
CommandParameter="{x:Static vm:QuickAccessTab.History}" />
|
||||||
|
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsAppearanceTab}" Content=""
|
||||||
|
AutomationProperties.Name="Appearance"
|
||||||
|
Command="{Binding Keyboard.SelectTabCommand}"
|
||||||
|
CommandParameter="{x:Static vm:QuickAccessTab.Appearance}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Back to the phone's own keyboard. The one control here that is not a tab, so it sits apart behind a
|
||||||
|
hairline: it does not change what this panel shows, it puts the panel away.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
|
||||||
|
<Border Width="1" Height="22" Background="{StaticResource Border}" VerticalAlignment="Center" />
|
||||||
|
<Button Classes="qtab" Content="" AutomationProperties.Name="Show the keyboard"
|
||||||
|
Click="OnKeyboardRequested" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Panel Grid.Row="1">
|
||||||
|
|
||||||
|
<!-- ============ KEYS ============ -->
|
||||||
|
<ScrollViewer IsVisible="{Binding Keyboard.IsKeysTab}" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Margin="8,4,8,12">
|
||||||
|
|
||||||
|
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center"
|
||||||
|
Command="{Binding Keyboard.OpenCustomizeCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text=""
|
||||||
|
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="label" FontSize="10" Text="CUSTOMIZE" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The bar's own keys, first, when the bar is not drawn — which is a wide screen with this panel
|
||||||
|
open beside the terminal. A key somebody put on their bar is one they want within reach, and
|
||||||
|
hiding the bar must not hide it.
|
||||||
|
-->
|
||||||
|
<StackPanel IsVisible="{Binding $parent[views:QuickAccessKeyboard].ShowsBarGroup}">
|
||||||
|
<TextBlock Classes="groupcaption" Text="YOUR BAR" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.BarRows}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyRowViewModel">
|
||||||
|
<ItemsControl ItemsSource="{Binding Keys}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyButtonViewModel">
|
||||||
|
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
|
||||||
|
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
|
||||||
|
Command="{Binding PressCommand}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.Groups}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyGroupViewModel">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Classes="groupcaption" Text="{Binding Title}" />
|
||||||
|
<!--
|
||||||
|
Four across, which at 360dp is an 84dp key — wide enough for "shift+tab" and "ctrl+B %"
|
||||||
|
in the mono face without shrinking it, and still four keys a thumb can tell apart.
|
||||||
|
-->
|
||||||
|
<ItemsControl ItemsSource="{Binding Keys}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyButtonViewModel">
|
||||||
|
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
|
||||||
|
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
|
||||||
|
Command="{Binding PressCommand}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- ============ SNIPPETS ============ -->
|
||||||
|
<!--
|
||||||
|
The keychain's snippets, through the shell's own InsertSnippet — the command the desktop sidebar's
|
||||||
|
rows run, which carries the snippets screen's whole safety story: typed rather than pasted for one
|
||||||
|
line, and no Enter unless the snippet was saved as one that runs. A second insert path here would be
|
||||||
|
a second place that story could go stale. The ▶ says which ones run.
|
||||||
|
-->
|
||||||
|
<ScrollViewer IsVisible="{Binding Keyboard.IsSnippetsTab}" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Margin="8,4,8,12" Spacing="2">
|
||||||
|
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible, FallbackValue={x:Null}}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||||
|
<Button Classes="row" MinHeight="48" Padding="12,6"
|
||||||
|
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Label}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Preview}"
|
||||||
|
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" FontSize="9" Text="▶ RUNS" Margin="10,0,0,0"
|
||||||
|
Foreground="{StaticResource WarnText}" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding RunsOnInsert}" />
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||||
|
Text="Tap a snippet to type it at the prompt. Snippets live in the keychain, where they are added and edited." />
|
||||||
|
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
|
||||||
|
Command="{Binding ShowScreenCommand}" CommandParameter="{x:Static vm:ShellScreen.Snippets}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="MANAGE SNIPPETS" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- ============ HISTORY ============ -->
|
||||||
|
<!--
|
||||||
|
What was typed on this phone and echoed back by the remote — never a line typed at a password prompt,
|
||||||
|
which echoes nothing. Kept in memory only and emptied by LOCK; the sentence under the list says so,
|
||||||
|
because a list of commands is exactly the kind of thing somebody should know the lifetime of. See
|
||||||
|
TypedCommandHistory for the whole of what is and is not kept.
|
||||||
|
-->
|
||||||
|
<ScrollViewer IsVisible="{Binding Keyboard.IsHistoryTab}" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Margin="8,4,8,12" Spacing="2">
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.History}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="kb:HistoryEntry">
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="12,6"
|
||||||
|
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).Keyboard.InsertHistoryCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5" Text="{Binding Command}"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="detail" FontSize="10" Text="{Binding Host}"
|
||||||
|
Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding !Keyboard.HasHistory}"
|
||||||
|
Text="Commands you type here appear in this list. Only what the remote echoes back is kept, so nothing typed at a password prompt ever is." />
|
||||||
|
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||||
|
Text="Tap a command to type it at the prompt without running it. The list is kept in memory only, and locking the keychain clears it." />
|
||||||
|
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
|
||||||
|
IsVisible="{Binding Keyboard.HasHistory}"
|
||||||
|
Command="{Binding Keyboard.ClearHistoryCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="CLEAR HISTORY" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- ============ APPEARANCE ============ -->
|
||||||
|
<!--
|
||||||
|
The text size, which used to ride at the end of the arrow row as A− and A+. A phone cannot press
|
||||||
|
Ctrl+plus, so these are still buttons; they moved here because the bar is now somebody's own and two
|
||||||
|
fixed cells at its end were cells they could not have. Disabled at the ends rather than clamping
|
||||||
|
silently, for the reason they always were: a button that accepts taps and does nothing reads as the
|
||||||
|
terminal having stopped responding.
|
||||||
|
|
||||||
|
And the cursor gesture's switch, for somebody whose long press means something else to them.
|
||||||
|
-->
|
||||||
|
<ScrollViewer IsVisible="{Binding Keyboard.IsAppearanceTab}" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Margin="12,6,12,12" Spacing="8">
|
||||||
|
|
||||||
|
<TextBlock Classes="groupcaption" Text="TEXT SIZE" Margin="0,4,0,0" />
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
|
<Button Grid.Column="0" Classes="hotkey" Width="56" Command="{Binding ShrinkTerminalFontCommand}"
|
||||||
|
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||||
|
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||||
|
</Button>
|
||||||
|
<TextBlock Grid.Column="1" Classes="mono" FontSize="14" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Text="{Binding TerminalFontSize, StringFormat='{}{0} px'}" />
|
||||||
|
<Button Grid.Column="2" Classes="hotkey" Width="56" Command="{Binding EnlargeTerminalFontCommand}"
|
||||||
|
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||||
|
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="3" Classes="row" MinHeight="40" Padding="12,0" Margin="8,0,0,0"
|
||||||
|
Command="{Binding ResetTerminalFontCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="RESET" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="groupcaption" Text="CURSOR GESTURE" Margin="0,10,0,0" />
|
||||||
|
<Button Classes="row" MinHeight="52" Padding="12,8" Command="{Binding Keyboard.ToggleCursorGesturesCommand}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2">
|
||||||
|
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="Long-press and drag" />
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
Text="Moves the cursor or walks through history. Drag further to go faster." />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="ON" Margin="10,0,0,0"
|
||||||
|
Foreground="{StaticResource Live}" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding Keyboard.CursorGestures}" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="OFF" Margin="10,0,0,0"
|
||||||
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding !Keyboard.CursorGestures}" />
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</Panel>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
|
/// <summary>The quick-access keyboard: keys, snippets, history and appearance, in four tabs.</summary>
|
||||||
|
internal sealed partial class QuickAccessKeyboard : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>Whether the KEYS tab repeats the bar's own keys at the top.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// On for the wide screen's side panel, where the bar is not drawn; off under a phone's terminal, where
|
||||||
|
/// the bar is directly above this and a second copy would be two rows of the same keys.
|
||||||
|
/// </remarks>
|
||||||
|
public static readonly StyledProperty<bool> ShowsBarGroupProperty =
|
||||||
|
AvaloniaProperty.Register<QuickAccessKeyboard, bool>(nameof(ShowsBarGroup));
|
||||||
|
|
||||||
|
public QuickAccessKeyboard() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keyboard button in the tab strip was pressed: put this panel away and bring the phone's back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An event rather than a command, because both halves are platform calls the shared view model has no
|
||||||
|
/// business making — showing the software keyboard means focusing the terminal's input surface, which
|
||||||
|
/// only <see cref="TerminalScreen"/> holds.
|
||||||
|
/// </remarks>
|
||||||
|
public event EventHandler? KeyboardRequested;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ShowsBarGroupProperty" />
|
||||||
|
public bool ShowsBarGroup
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsBarGroupProperty);
|
||||||
|
set => SetValue(ShowsBarGroupProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnKeyboardRequested(object? sender, RoutedEventArgs e) =>
|
||||||
|
KeyboardRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
@@ -110,6 +110,34 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
</Style>
|
</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>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
@@ -139,7 +167,7 @@
|
|||||||
job — see the remark on the header in PhoneShell.axaml. -->
|
job — see the remark on the header in PhoneShell.axaml. -->
|
||||||
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
|
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
|
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
|
||||||
@@ -244,16 +272,66 @@
|
|||||||
HOSTS, which is the only action on its screen; this one is not.
|
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
|
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}" />
|
Command="{Binding OpenConnectSheetCommand}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The wide screen's side panel, shown and hidden — the quick-access keyboard beside the terminal
|
||||||
|
rather than under it. Here rather than at the start of the key bar, which is where a phone keeps
|
||||||
|
its toggle: on a tablet the bar is not drawn while the panel is open, and the control that brings
|
||||||
|
the panel back cannot live inside the thing the panel replaced. Lit while open, like a latched key.
|
||||||
|
-->
|
||||||
|
<Button Grid.Column="4" Classes="icon" Height="34" Margin="0,0,4,0"
|
||||||
|
Classes.accent="{Binding Keyboard.IsSidePanelOpen}"
|
||||||
|
AutomationProperties.Name="Keys, snippets and history"
|
||||||
|
IsVisible="{Binding $parent[views:TerminalScreen].IsWide}"
|
||||||
|
Command="{Binding Keyboard.ToggleSidePanelCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ============ the renderer ============ -->
|
<!-- ============ the renderer, and the wide screen's side panel ============ -->
|
||||||
<Panel Grid.Row="1">
|
<!--
|
||||||
|
Two columns, and the second is collapsed except on a wide screen with the panel open. Beside the
|
||||||
|
renderer rather than over it, for the reason nothing on this head is drawn over it: the WebView is a
|
||||||
|
native child composited above everything Avalonia paints, so a panel laid on top would be invisible.
|
||||||
|
Taking its width off the terminal refits the grid and tells the remote, exactly as the software
|
||||||
|
keyboard taking its height does.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
|
||||||
|
<Panel Grid.Column="0">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
============ nothing open ============
|
============ nothing open ============
|
||||||
@@ -496,60 +574,21 @@
|
|||||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- ============ accessory keys ============ -->
|
|
||||||
<!--
|
<!--
|
||||||
The row every Android SSH client ships, and the reason is not preference: a software keyboard has no
|
The quick-access keyboard as a side panel: additional keys, snippets, history and appearance in view
|
||||||
Ctrl, no Esc, no Tab and no arrows, and without them a phone cannot interrupt a process, complete a
|
while the terminal is worked in, which is what a tablet's width is for. 320 wide, which at the 600dp
|
||||||
path, or reach the previous command. Ctrl and Alt latch — pressed once they apply to the next key and
|
where this head turns wide leaves the terminal 280 — a narrow terminal, and a panel somebody can close
|
||||||
then release, because holding a modifier while typing is not possible one-thumbed.
|
from the bar above; at a tablet's 900 it is a third.
|
||||||
|
|
||||||
|
ShowsBarGroup, because the bar under the terminal stands down while this is open and the keys someone
|
||||||
|
put on it have to go somewhere they can still be reached.
|
||||||
-->
|
-->
|
||||||
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
<Border Grid.Column="1" Width="320" BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
||||||
<!--
|
IsVisible="{Binding $parent[views:TerminalScreen].ShowsSidePanel}">
|
||||||
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
|
<views:QuickAccessKeyboard x:Name="SidePanel" ShowsBarGroup="True" />
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
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 ============ -->
|
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||||
<!--
|
<!--
|
||||||
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
|
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
|
||||||
@@ -599,71 +638,90 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
|
<!-- ============ the keys ============ -->
|
||||||
<StackPanel Spacing="4">
|
<!--
|
||||||
|
The keys a software keyboard does not have, and the way to all the others.
|
||||||
|
|
||||||
|
── the bar ──────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
The row every Android SSH client ships, and the reason is not preference: a software keyboard has no
|
||||||
|
Ctrl, no Esc, no Tab and no arrows, and without them a phone cannot interrupt a process, complete a
|
||||||
|
path, or reach the previous command. Ctrl and Alt latch — pressed once they apply to the next key, from
|
||||||
|
the bar or from the phone's own keyboard, and then release — because holding a modifier while typing is
|
||||||
|
not possible one-thumbed.
|
||||||
|
|
||||||
|
It is somebody's own now: which keys, in what order, set on the customise screen and kept on this
|
||||||
|
device. What did not change is the shape the old fixed keypad argued its way to, and the argument still
|
||||||
|
holds. Nothing here scrolls — a horizontally scrollable strip reads a thumb coming down at an angle as
|
||||||
|
the start of a pan, and the key never sees the press. Every key gets an equal share of its row's width,
|
||||||
|
so "ctrl" and "|" are the same target. At most eight to a row and two rows, which keeps a key at about
|
||||||
|
41dp on a 360dp phone; anything past sixteen belongs on the quick-access keyboard. The first eight are
|
||||||
|
the top row, so a person arranging their bar decides the rows by the order alone.
|
||||||
|
|
||||||
|
As shipped: modifiers and punctuation on top, cursor keys underneath, nearest the thumb, because they
|
||||||
|
are the keys pressed repeatedly and in sequence — history, then along the line.
|
||||||
|
|
||||||
|
── the grid button ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
At the bar's left-hand end, outside the rows, and the one key nobody can remove: it is the way to the
|
||||||
|
quick-access keyboard and from there to the customise screen, so a bar arranged to nothing is still a
|
||||||
|
bar with a way back. Lit while the panel is up, as a latched modifier is, because it is a state.
|
||||||
|
|
||||||
|
── the panel ────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
The quick-access keyboard, in the software keyboard's place: the code-behind puts the phone's keyboard
|
||||||
|
away as this opens and gives this the height that keyboard last had, so the bar does not move between
|
||||||
|
the two. Tapping the terminal brings the phone's keyboard back and puts this away again.
|
||||||
|
|
||||||
|
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 is flush against a row of grey keys reads as one surface gone wrong.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Row="3" IsVisible="{Binding $parent[views:TerminalScreen].ShowsKeys}">
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*" Margin="8,5,8,4">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
|
Focusable="False", for the reason every key carries it — see the remark on Button.hotkey's use in
|
||||||
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
|
QuickAccessKeyboard. A button here that took focus would take it off the terminal's input surface,
|
||||||
between them instead of taking what their labels happen to need — "ctrl" is four characters and
|
and the keyboard would go with it.
|
||||||
"|" is one, and on a row of keys those two want the same target, not proportional ones.
|
|
||||||
-->
|
-->
|
||||||
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
|
<Button Grid.Column="0" Classes="hotkey" Width="44" Focusable="False" VerticalAlignment="Bottom"
|
||||||
|
Margin="0,0,4,0"
|
||||||
<!--
|
Classes.latched="{Binding Keyboard.IsQuickAccessOpen}"
|
||||||
The cursor keys, and the two text-size buttons riding at the end.
|
AutomationProperties.Name="Keys, snippets and history"
|
||||||
|
IsVisible="{Binding !$parent[views:TerminalScreen].IsWide}"
|
||||||
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
|
Click="OnQuickAccessToggled">
|
||||||
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||||
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
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>
|
||||||
|
|
||||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
<ItemsControl Grid.Column="1" ItemsSource="{Binding Keyboard.BarRows}">
|
||||||
Command="{Binding EnlargeTerminalFontCommand}"
|
<ItemsControl.ItemsPanel>
|
||||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
<ItemsPanelTemplate><StackPanel Spacing="4" /></ItemsPanelTemplate>
|
||||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
</ItemsControl.ItemsPanel>
|
||||||
</Button>
|
<ItemsControl.ItemTemplate>
|
||||||
</StackPanel>
|
<DataTemplate x:DataType="vm:KeyRowViewModel">
|
||||||
|
<ItemsControl ItemsSource="{Binding Keys}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><UniformGrid Rows="1" /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyButtonViewModel">
|
||||||
|
<Button Classes="hotkey" Focusable="False" Classes.latched="{Binding IsLatched}"
|
||||||
|
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
|
||||||
|
Command="{Binding PressCommand}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
|
||||||
|
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||||
|
Height="{Binding $parent[views:TerminalScreen].QuickAccessHeight}"
|
||||||
|
IsVisible="{Binding $parent[views:TerminalScreen].ShowsQuickAccessPanel}">
|
||||||
|
<views:QuickAccessKeyboard x:Name="BottomPanel" />
|
||||||
</Border>
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -742,6 +800,254 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Panel>
|
</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>
|
||||||
|
|
||||||
|
<!-- ============ the customise sheet ============ -->
|
||||||
|
<!--
|
||||||
|
Reached from CUSTOMIZE at the top of the quick-access keyboard's KEYS tab: which keys are on the bar
|
||||||
|
and in what order, which groups the KEYS tab shows and in what order, and keys of one's own.
|
||||||
|
|
||||||
|
Taller than the other two sheets, nearly the whole display, because it is a page of settings rather
|
||||||
|
than a menu — and it scrolls for the same reason. It takes the screen the way they do too: opening it
|
||||||
|
collapses the renderer (see MainWindowViewModel.IsTerminalShowing), so nothing is drawn over the
|
||||||
|
WebView and the boxes on it can have the keyboard.
|
||||||
|
|
||||||
|
Every change applies the moment it is made, to the bar and to the settings file, which is why there is
|
||||||
|
DONE and no SAVE. See TerminalKeyboardViewModel.
|
||||||
|
|
||||||
|
↑ and ↓ rather than drag handles. A drag inside a scrolling sheet is the same gesture as a scroll and
|
||||||
|
has to be told apart by a long press nobody can see; two buttons are slower and never wrong.
|
||||||
|
-->
|
||||||
|
<Panel IsVisible="{Binding Keyboard.IsCustomizing}">
|
||||||
|
|
||||||
|
<Button Classes="scrim" Command="{Binding Keyboard.CloseCustomizeCommand}" />
|
||||||
|
|
||||||
|
<Border Margin="0,40,0,0" Background="{StaticResource Panel}"
|
||||||
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||||
|
CornerRadius="22,22,0,0" Padding="20,18,20,0">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*">
|
||||||
|
|
||||||
|
<Border Grid.Row="0" Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||||
|
HorizontalAlignment="Center" Margin="0,0,0,12" />
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="0,0,0,8">
|
||||||
|
<TextBlock Grid.Column="0" Classes="title" Text="CUSTOMIZE KEYS" FontSize="13"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button Grid.Column="1" Classes="row" MinHeight="40" Padding="14,0"
|
||||||
|
Command="{Binding Keyboard.CloseCustomizeCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="DONE" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Spacing="6" Margin="0,0,0,24">
|
||||||
|
|
||||||
|
<!-- ── the bar ── -->
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="THE BAR" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="detail" FontSize="10" Text="{Binding Keyboard.BarCountText}" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||||
|
Text="The first eight keys are the top row and the rest the second. Tap a key in a group below to put it on the bar or take it off." />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.BarEditor}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:BarEditorRowViewModel">
|
||||||
|
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,4">
|
||||||
|
<Grid ColumnDefinitions="64,*,Auto,Auto,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||||
|
Text="{Binding Label}" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<StackPanel Grid.Column="1" Spacing="1" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Hint}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="detail" FontSize="9" Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="{Binding Row}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Move earlier" Command="{Binding MoveUpCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="3" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Move later" Command="{Binding MoveDownCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="4" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Take off the bar" Command="{Binding RemoveCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||||
|
Foreground="{StaticResource TextFaint}" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap" IsVisible="{Binding !Keyboard.HasBar}"
|
||||||
|
Text="The bar is empty, so only the grid button is drawn above the keyboard." />
|
||||||
|
|
||||||
|
<!-- ── the groups ── -->
|
||||||
|
<TextBlock Classes="label" Text="GROUPS" Margin="0,16,0,0" />
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||||
|
Text="The quick-access keyboard shows the ticked groups, in this order. A lit key is on the bar." />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.GroupEditor}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:GroupEditorRowViewModel">
|
||||||
|
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,6,0,10">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||||
|
<CheckBox Grid.Column="0" IsChecked="{Binding IsShown}" MinHeight="40">
|
||||||
|
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Title}" />
|
||||||
|
</CheckBox>
|
||||||
|
<Button Grid.Column="1" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Move up" Command="{Binding MoveUpCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Move down" Command="{Binding MoveDownCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<ItemsControl ItemsSource="{Binding Keys}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyChoiceViewModel">
|
||||||
|
<Button Classes="chiptoggle" Classes.worn="{Binding IsOnBar}" Margin="0,0,6,6"
|
||||||
|
Command="{Binding ToggleCommand}">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- ── keys of one's own ── -->
|
||||||
|
<TextBlock Classes="label" Text="ADD A KEY" Margin="0,16,0,0" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.CustomKeyEditor}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:CustomKeyRowViewModel">
|
||||||
|
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,4">
|
||||||
|
<Grid ColumnDefinitions="64,*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||||
|
Text="{Binding Label}" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="detail" FontSize="10.5" Text="{Binding Sequence}"
|
||||||
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
|
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Delete this key" Command="{Binding DeleteCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||||
|
Foreground="{StaticResource DangerText}" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ShowSuggestions off on both boxes, for the reason the terminal's own input surface turns it off:
|
||||||
|
an autocorrecting keyboard capitalises the e in \e and puts a full stop after ^C, and the key
|
||||||
|
then sends something nobody typed.
|
||||||
|
-->
|
||||||
|
<TextBox Classes="field" Text="{Binding Keyboard.NewKeyLabel}" MaxLength="10"
|
||||||
|
TextInputOptions.ShowSuggestions="False"
|
||||||
|
PlaceholderText="Label, as it appears on the key" />
|
||||||
|
<TextBox Classes="field" Text="{Binding Keyboard.NewKeySequence}"
|
||||||
|
TextInputOptions.ShowSuggestions="False"
|
||||||
|
PlaceholderText="What it sends, e.g. \e:wq\r or ^C" />
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||||
|
Text="\e is Escape, \r is Enter, \t is Tab, and ^ before a letter is Ctrl; \^ is a plain caret. Keys are kept on this phone in plain text, so this is no place for a password." />
|
||||||
|
<Button Classes="primary" Content="ADD KEY" Command="{Binding Keyboard.AddCustomKeyCommand}" />
|
||||||
|
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||||
|
Text="{Binding Keyboard.CustomizeStatus}"
|
||||||
|
IsVisible="{Binding Keyboard.CustomizeStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
|
<Button Classes="secondary" Content="RESET TO DEFAULTS" Margin="0,16,0,0"
|
||||||
|
Command="{Binding Keyboard.ResetLayoutCommand}" />
|
||||||
|
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||||
|
Text="Puts the bar and the groups back as they shipped. Keys you added are kept." />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Platform;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Layout;
|
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
using DodoSSH.Client.Android.Platform;
|
using DodoSSH.Client.Android.Platform;
|
||||||
|
using DodoSSH.Client.Shell.Keyboard;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
|
// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
|
||||||
@@ -22,85 +21,45 @@ using Button = Avalonia.Controls.Button;
|
|||||||
|
|
||||||
namespace DodoSSH.Client.Android.Views;
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
/// <summary>Design 03 — the terminal, and the accessory key row under it.</summary>
|
/// <summary>Design 03 — the terminal, and the keys under or beside it.</summary>
|
||||||
internal sealed partial class TerminalScreen : UserControl
|
internal sealed partial class TerminalScreen : UserControl
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The keys a software keyboard does not have.
|
/// The height the quick-access keyboard takes on a phone until the software keyboard has been measured.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// About what Gboard and Samsung's keyboard take in portrait. It is only a starting point: the first time
|
||||||
/// Chosen by what a shell session actually needs rather than by what a keyboard has: Esc leaves vi's
|
/// the software keyboard is up, its own height replaces this, so the bar above the panel does not move
|
||||||
/// insert mode, Tab completes a path, Ctrl makes <c>^C</c> reachable, the arrows reach history, and the
|
/// when one is swapped for the other.
|
||||||
/// pipe and hyphen are two characters that are three taps deep on every Android keyboard and appear in
|
|
||||||
/// almost every command worth typing on a phone.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The order matches the design's row. It is scrollable rather than compressed, because shrinking ten
|
|
||||||
/// keys to fit 360dp produces ten targets too small to hit.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <summary>The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.</summary>
|
private const double DefaultQuickAccessHeight = 280;
|
||||||
/// <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),
|
|
||||||
("ctrl", [], true),
|
|
||||||
("|", [(byte)'|'], false),
|
|
||||||
("-", [(byte)'-'], false),
|
|
||||||
("~", [(byte)'~'], false),
|
|
||||||
("/", [(byte)'/'], false),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
|
public static readonly StyledProperty<bool> IsWideProperty =
|
||||||
/// <remarks>
|
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(IsWide));
|
||||||
/// <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),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// <summary>
|
public static readonly StyledProperty<bool> ShowsKeysProperty =
|
||||||
/// Whether the next ordinary key should be sent as a control character.
|
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsKeys));
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Latching rather than held. Holding a modifier while typing needs two hands and a keyboard that
|
|
||||||
/// reports chords, and this row has neither — so Ctrl is pressed, then C, and releases itself. Every
|
|
||||||
/// Android SSH client does this and users expect it.
|
|
||||||
/// </remarks>
|
|
||||||
private bool controlLatched;
|
|
||||||
|
|
||||||
private Button? controlKey;
|
public static readonly StyledProperty<bool> ShowsQuickAccessPanelProperty =
|
||||||
|
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsQuickAccessPanel));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> ShowsSidePanelProperty =
|
||||||
|
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsSidePanel));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<double> QuickAccessHeightProperty =
|
||||||
|
AvaloniaProperty.Register<TerminalScreen, double>(nameof(QuickAccessHeight), DefaultQuickAccessHeight);
|
||||||
|
|
||||||
private readonly TerminalInputSurface input;
|
private readonly TerminalInputSurface input;
|
||||||
|
|
||||||
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
|
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
|
||||||
private MainWindowViewModel? subscribed;
|
private MainWindowViewModel? subscribed;
|
||||||
|
|
||||||
|
/// <summary>The phone shell this screen sits in, for its width class.</summary>
|
||||||
|
private PhoneShell? phoneShell;
|
||||||
|
|
||||||
|
/// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary>
|
||||||
|
private IInputPane? softKeyboard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
|
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -115,10 +74,14 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
BuildAccessoryRow();
|
|
||||||
|
|
||||||
input = this.FindControl<TerminalInputSurface>("Input")!;
|
input = this.FindControl<TerminalInputSurface>("Input")!;
|
||||||
input.Input += (_, e) => SendAsync(e.Bytes);
|
|
||||||
|
// Everything typed on the phone's own keyboard goes through the same path as the bar's keys, which is
|
||||||
|
// what lets Ctrl latched on the bar apply to a letter typed on Gboard. See TerminalKeyboardViewModel.
|
||||||
|
input.Input += (_, e) => Keyboard?.Send(e.Bytes);
|
||||||
|
|
||||||
|
this.FindControl<QuickAccessKeyboard>("BottomPanel")!.KeyboardRequested += OnKeyboardRequested;
|
||||||
|
this.FindControl<QuickAccessKeyboard>("SidePanel")!.KeyboardRequested += OnKeyboardRequested;
|
||||||
|
|
||||||
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
||||||
|
|
||||||
@@ -140,6 +103,7 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
{
|
{
|
||||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||||
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||||
|
previous.Keyboard.PropertyChanged -= OnKeyboardPropertyChanged;
|
||||||
subscribed = null;
|
subscribed = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,13 +117,250 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
|
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
|
||||||
// they look. See MainWindowViewModel.TerminalFocusRequested.
|
// they look. See MainWindowViewModel.TerminalFocusRequested.
|
||||||
shell.TerminalFocusRequested += OnTerminalFocusRequested;
|
shell.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||||
|
shell.Keyboard.PropertyChanged += OnKeyboardPropertyChanged;
|
||||||
subscribed = shell;
|
subscribed = shell;
|
||||||
|
|
||||||
terminalWasShowing = shell.IsTerminalShowing;
|
terminalWasShowing = shell.IsTerminalShowing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RefreshKeys();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the screen is wide enough for the side panel. <see cref="PhoneShell.IsWide"/>, mirrored.</summary>
|
||||||
|
public bool IsWide
|
||||||
|
{
|
||||||
|
get => GetValue(IsWideProperty);
|
||||||
|
private set => SetValue(IsWideProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the bar under the terminal is drawn.</summary>
|
||||||
|
public bool ShowsKeys
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsKeysProperty);
|
||||||
|
private set => SetValue(ShowsKeysProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the quick-access keyboard is drawn under the bar, in the software keyboard's place.</summary>
|
||||||
|
public bool ShowsQuickAccessPanel
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsQuickAccessPanelProperty);
|
||||||
|
private set => SetValue(ShowsQuickAccessPanelProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the quick-access keyboard is drawn beside the terminal.</summary>
|
||||||
|
public bool ShowsSidePanel
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsSidePanelProperty);
|
||||||
|
private set => SetValue(ShowsSidePanelProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How tall the quick-access keyboard is on a phone: the software keyboard's last height.</summary>
|
||||||
|
public double QuickAccessHeight
|
||||||
|
{
|
||||||
|
get => GetValue(QuickAccessHeightProperty);
|
||||||
|
private set => SetValue(QuickAccessHeightProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TerminalKeyboardViewModel? Keyboard => (DataContext as MainWindowViewModel)?.Keyboard;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnAttachedToVisualTree(e);
|
||||||
|
|
||||||
|
phoneShell = this.FindAncestorOfType<PhoneShell>();
|
||||||
|
|
||||||
|
if (phoneShell is not null)
|
||||||
|
{
|
||||||
|
phoneShell.PropertyChanged += OnPhoneShellPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
softKeyboard = TopLevel.GetTopLevel(this)?.InputPane;
|
||||||
|
|
||||||
|
if (softKeyboard is not null)
|
||||||
|
{
|
||||||
|
softKeyboard.StateChanged += OnSoftKeyboardChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
if (phoneShell is not null)
|
||||||
|
{
|
||||||
|
phoneShell.PropertyChanged -= OnPhoneShellPropertyChanged;
|
||||||
|
phoneShell = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (softKeyboard is not null)
|
||||||
|
{
|
||||||
|
softKeyboard.StateChanged -= OnSoftKeyboardChanged;
|
||||||
|
softKeyboard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnDetachedFromVisualTree(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Works out which of the three key surfaces is drawn: the bar, the panel under it, the panel beside.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Computed here rather than written as conditions in the markup, for PhoneShell's reason: each is an
|
||||||
|
/// "and" of facts from three objects — the shell, the keyboard and the width — and a binding has no
|
||||||
|
/// "and". Recomputed on any notification from any of the three, which is cheaper than being wrong about
|
||||||
|
/// which of their properties are computed ones.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wide, the side panel replaces the bar while it is open.</b> Its KEYS tab repeats the bar's keys at
|
||||||
|
/// the top, so nothing somebody put on their bar goes out of reach, and a tablet keeps the height. Closed,
|
||||||
|
/// the bar comes back exactly as on a phone.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wide, the bottom panel never shows.</b> Rotating a phone into the wide class with the panel up would
|
||||||
|
/// otherwise leave it raised under a terminal that also has the side panel; it is lowered instead, and the
|
||||||
|
/// side panel is where the same tabs are.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void RefreshKeys()
|
||||||
|
{
|
||||||
|
var wide = phoneShell?.IsWide == true;
|
||||||
|
var showing = subscribed?.IsTerminalShowing == true;
|
||||||
|
var keyboard = subscribed?.Keyboard;
|
||||||
|
var sidePanel = wide && keyboard?.IsSidePanelOpen == true;
|
||||||
|
|
||||||
|
if (wide && keyboard is { IsQuickAccessOpen: true })
|
||||||
|
{
|
||||||
|
keyboard.CloseQuickAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
IsWide = wide;
|
||||||
|
ShowsSidePanel = showing && sidePanel;
|
||||||
|
ShowsKeys = showing && !sidePanel;
|
||||||
|
ShowsQuickAccessPanel = showing && !wide && keyboard?.IsQuickAccessOpen == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPhoneShellPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Property == PhoneShell.IsWideProperty)
|
||||||
|
{
|
||||||
|
RefreshKeys();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnKeyboardPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
switch (e.PropertyName)
|
||||||
|
{
|
||||||
|
case nameof(TerminalKeyboardViewModel.IsQuickAccessOpen)
|
||||||
|
or nameof(TerminalKeyboardViewModel.IsSidePanelOpen):
|
||||||
|
RefreshKeys();
|
||||||
|
break;
|
||||||
|
|
||||||
|
// The customise sheet has boxes of its own, and on a wide screen it can be opened while the
|
||||||
|
// terminal's keyboard is up. Put away here so the sheet is laid out into the whole display
|
||||||
|
// rather than the strip above a keyboard configured for a shell.
|
||||||
|
case nameof(TerminalKeyboardViewModel.IsCustomizing)
|
||||||
|
when sender is TerminalKeyboardViewModel { IsCustomizing: true }:
|
||||||
|
SoftKeyboard.Hide();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notes the software keyboard's height, and lowers the quick-access keyboard when it comes back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The two cannot both be up on a phone — they are the same place on the screen — and the software
|
||||||
|
/// keyboard arriving is the one signal every way of bringing it back has in common: a tap on the
|
||||||
|
/// terminal, the panel's own keyboard button, a box elsewhere taking focus.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The height is kept only when it is plausibly a keyboard. Before Android 15 the window is resized for
|
||||||
|
/// the keyboard instead and the inset reported here is zero (see PhoneShell.ApplyKeyboardInset); a panel
|
||||||
|
/// sized from that would be no panel at all, so the default stands.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnSoftKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.NewState is not InputPaneState.Open)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RememberKeyboardHeight(e.EndRect.Height);
|
||||||
|
Keyboard?.CloseQuickAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RememberKeyboardHeight(double height)
|
||||||
|
{
|
||||||
|
if (double.IsFinite(height) && height >= 160)
|
||||||
|
{
|
||||||
|
QuickAccessHeight = Math.Min(height, 480);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The grid button: swaps the software keyboard for the quick-access keyboard, or back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A click handler rather than the view model's toggle alone, because both halves of the swap are
|
||||||
|
/// platform calls: the software keyboard is measured and put away on the way in, and brought back by
|
||||||
|
/// focusing the input surface on the way out. The view model only records which of the two is up.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The panel is raised before the keyboard is hidden. The two arrive as separate layout passes either
|
||||||
|
/// way; this order means the frame between them is the panel over a closing keyboard rather than a
|
||||||
|
/// terminal briefly the full height of the display.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnQuickAccessToggled(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (Keyboard is not { } keyboard)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyboard.IsQuickAccessOpen)
|
||||||
|
{
|
||||||
|
keyboard.CloseQuickAccess();
|
||||||
|
input.Acquire();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (softKeyboard is { State: InputPaneState.Open } pane)
|
||||||
|
{
|
||||||
|
RememberKeyboardHeight(pane.OccludedRect.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboard.ToggleQuickAccessCommand.Execute(null);
|
||||||
|
SoftKeyboard.Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The panel's keyboard button: put the panel away and bring the phone's keyboard back.</summary>
|
||||||
|
private void OnKeyboardRequested(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Keyboard?.CloseQuickAccess();
|
||||||
|
input.Acquire();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A tap on the terminal: the phone's keyboard, in place of the panel if that was up.</summary>
|
||||||
|
private void OnRendererTapped()
|
||||||
|
{
|
||||||
|
Keyboard?.CloseQuickAccess();
|
||||||
|
input.Acquire();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The cursor gesture's arrows, from <see cref="RendererFocus"/>.</summary>
|
||||||
|
private void OnCursorGesture(ArrowDirection direction, int count) =>
|
||||||
|
Keyboard?.SendArrow(direction, count);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
|
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -171,6 +372,8 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RefreshKeys();
|
||||||
|
|
||||||
var showing = shell.IsTerminalShowing;
|
var showing = shell.IsTerminalShowing;
|
||||||
|
|
||||||
if (showing == terminalWasShowing)
|
if (showing == terminalWasShowing)
|
||||||
@@ -207,13 +410,18 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
|
/// 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.
|
/// the tap has to be reported rather than left to the page.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
|
private void AdoptRenderer() =>
|
||||||
|
RendererFocus.Suppress(OnRendererTapped, () => Keyboard?.CursorGestures == true, OnCursorGesture);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Puts the keyboard away on the way to the connect menu.
|
/// Puts the keyboard away on the way to the connect menu or the forwards sheet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <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
|
/// 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
|
/// on the shared view model and this is a platform call: the desktop head has no software keyboard to
|
||||||
/// put away, and a shell view model that knew about Android's input-method manager would be the first
|
/// put away, and a shell view model that knew about Android's input-method manager would be the first
|
||||||
@@ -222,7 +430,7 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The keyboard is up in the ordinary case.</b> This button sits above a terminal somebody is typing
|
/// <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 —
|
/// 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
|
/// phone draws (see <c>PhoneShell.ApplyKeyboardInset</c>), so the sheet would be laid out into the
|
||||||
/// remaining strip rather than merely hidden behind it.
|
/// remaining strip rather than merely hidden behind it.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -231,7 +439,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.
|
/// what the other writes: this one talks to the platform and the command sets a flag, so they commute.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnConnectMenuRequested(object? sender, RoutedEventArgs e) => SoftKeyboard.Hide();
|
private void OnSheetRequested(object? sender, RoutedEventArgs e) => SoftKeyboard.Hide();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Takes the browser gestures back off a surface that is not a web page.
|
/// Takes the browser gestures back off a surface that is not a web page.
|
||||||
@@ -256,129 +464,4 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
android.BuiltInZoomControls = false;
|
android.BuiltInZoomControls = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BuildAccessoryRow()
|
|
||||||
{
|
|
||||||
// ── 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.
|
|
||||||
|
|
||||||
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
|
|
||||||
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Puts one table's worth of keys into one row of the keypad.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The rows differ in what they hold and in nothing else — same control, same class, same wiring — so
|
|
||||||
/// they are filled by one method rather than by two that would drift.
|
|
||||||
/// </remarks>
|
|
||||||
private void Fill(UniformGrid row, (string Label, byte[] Bytes, bool Latches)[] keys)
|
|
||||||
{
|
|
||||||
foreach (var (label, bytes, latches) in keys)
|
|
||||||
{
|
|
||||||
var key = CreateKey(label);
|
|
||||||
|
|
||||||
if (latches)
|
|
||||||
{
|
|
||||||
controlKey = key;
|
|
||||||
key.Click += (_, _) => ToggleControl();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
key.Click += (_, _) => SendAsync(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.Children.Add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>One key of the accessory row, before its click is wired.</summary>
|
|
||||||
/// <remarks>Split from <see cref="BuildAccessoryRow"/> for length rather than for reuse.</remarks>
|
|
||||||
private static Button CreateKey(string label) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Content = label,
|
|
||||||
|
|
||||||
// ◆ Every dimension and brush this used to set by hand is now Button.hotkey in Phone.axaml,
|
|
||||||
// and the move is what lets the row have a pressed state at all: a style can say :pressed and
|
|
||||||
// an assignment cannot. It also takes this file out of the business of naming the palette's
|
|
||||||
// brushes, which is how a keypad ends up with a grey nothing else uses.
|
|
||||||
//
|
|
||||||
// Content is the string rather than a TextBlock built here for the same reason — the class
|
|
||||||
// carries the font and the alignment, so a wrapper element would only be somewhere for them to
|
|
||||||
// disagree.
|
|
||||||
Classes = { "hotkey" },
|
|
||||||
|
|
||||||
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
|
||||||
//
|
|
||||||
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
|
||||||
// ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
|
|
||||||
// terminal's input target is — and a modifier row that dismisses the keyboard every time it
|
|
||||||
// is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
|
|
||||||
// the focused element never changes: TerminalInputSurface is still it, so the input
|
|
||||||
// connection is never torn down and nothing has to be handed back.
|
|
||||||
//
|
|
||||||
// This used to be only half a fix, because the target was the NativeWebView and Android keeps
|
|
||||||
// a focus of its own that the platform hands to Avalonia's view after every touch it
|
|
||||||
// dispatches. That half is gone rather than countered: under ADR 0015 the input target is an
|
|
||||||
// Avalonia control, so the focus Android moves is already where the keyboard should be.
|
|
||||||
Focusable = false,
|
|
||||||
};
|
|
||||||
|
|
||||||
private void ToggleControl()
|
|
||||||
{
|
|
||||||
controlLatched = !controlLatched;
|
|
||||||
|
|
||||||
if (controlKey is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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
|
|
||||||
/// different sequence entirely and silently mangling one into a control byte would be worse than
|
|
||||||
/// ignoring the latch.
|
|
||||||
/// </remarks>
|
|
||||||
private void SendAsync(byte[] bytes)
|
|
||||||
{
|
|
||||||
if (DataContext is not MainWindowViewModel { SelectedTab: { } tab } shell)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload = bytes;
|
|
||||||
|
|
||||||
if (controlLatched && bytes.Length == 1)
|
|
||||||
{
|
|
||||||
var c = bytes[0];
|
|
||||||
|
|
||||||
if (c is >= (byte)'a' and <= (byte)'z' or >= (byte)'A' and <= (byte)'Z')
|
|
||||||
{
|
|
||||||
payload = [(byte)(c & 0x1F)];
|
|
||||||
}
|
|
||||||
|
|
||||||
ToggleControl();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has
|
|
||||||
// gone, and a button handler that awaited would serialise the row behind a slow link.
|
|
||||||
_ = shell.SendTerminalInputAsync(tab.SessionId, payload).AsTask();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Sends a literal string, for the keys that carry text rather than a control code.</summary>
|
|
||||||
/// <remarks>Kept because the snippet feature will type into a terminal exactly this way.</remarks>
|
|
||||||
internal void Send(string text) => SendAsync(Encoding.UTF8.GetBytes(text));
|
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,20 @@ namespace DodoSSH.Client.App;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class DodoSshApp : Application
|
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 />
|
/// <inheritdoc />
|
||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
@@ -34,12 +48,63 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime 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);
|
ShowSomethingWhileComposing(desktop);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
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>
|
/// <summary>
|
||||||
/// Puts a window on screen first, and composes the application behind it.
|
/// Puts a window on screen first, and composes the application behind it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -206,9 +271,20 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
// 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
|
// 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 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(
|
var others = SingleInstance.Listen(
|
||||||
paths,
|
paths,
|
||||||
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
|
() => 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
|
// 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
|
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||||
@@ -280,39 +356,9 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
copyToClipboard: ClipboardWriter(desktop),
|
copyToClipboard: ClipboardWriter(desktop),
|
||||||
readClipboard: ClipboardReader(desktop),
|
readClipboard: ClipboardReader(desktop),
|
||||||
updates: updates,
|
updates: updates,
|
||||||
openInEditor: OpenInDefaultEditorAsync,
|
openInEditor: LocalEditor.OpenAsync,
|
||||||
watchFile: WatchFile);
|
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>
|
/// <summary>
|
||||||
/// Watches one file for saves, until the handle is disposed.
|
/// Watches one file for saves, until the handle is disposed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -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;
|
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>
|
/// <summary>Creates the store.</summary>
|
||||||
public MacDeviceKeyStore(ClientPaths paths)
|
public MacDeviceKeyStore(ClientPaths paths)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
|
keyTag = DeviceKeyNames.For(paths, KeyTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -163,7 +173,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
ReadOnlyMemory<byte> devicePrivateKey,
|
ReadOnlyMemory<byte> devicePrivateKey,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sealedKey = Seal(devicePrivateKey.Span)
|
var sealedKey = Seal(devicePrivateKey.Span, keyTag)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
|
"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)
|
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.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 />
|
/// <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,
|
// 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
|
// 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.
|
// left in the enclave with no ciphertext to open is inert.
|
||||||
_ = DeleteKey(symbols, KeyTag);
|
_ = DeleteKey(symbols, keyTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
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
|
/// 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.
|
/// them is the same — do not offer a device unlock.
|
||||||
/// </remarks>
|
/// </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
|
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
|
// 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.
|
// 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)
|
if (privateKey == IntPtr.Zero)
|
||||||
{
|
{
|
||||||
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
|
privateKey = CreateEnclaveKey(scope, symbols, tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (privateKey == IntPtr.Zero)
|
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.
|
/// on a thread that is also expected to draw the window behind it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static byte[]? Unseal(byte[] sealedKey)
|
private static byte[]? Unseal(byte[] sealedKey, string tag)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -294,7 +324,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
|
var privateKey = FindKey(scope, symbols, tag, ConsentPrompt);
|
||||||
|
|
||||||
if (privateKey == IntPtr.Zero)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ namespace DodoSSH.Client.App.Platform;
|
|||||||
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
/// 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.
|
/// what makes the exclusion invisible rather than mysterious.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
internal static class SingleInstance
|
internal static class SingleInstance
|
||||||
{
|
{
|
||||||
@@ -46,6 +52,30 @@ internal static class SingleInstance
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const string ComeForward = "dodossh.come-forward";
|
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>
|
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
||||||
@@ -86,7 +116,11 @@ internal static class SingleInstance
|
|||||||
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
/// 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.
|
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
||||||
/// </param>
|
/// </param>
|
||||||
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
|
/// <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);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
@@ -116,7 +150,7 @@ internal static class SingleInstance
|
|||||||
|
|
||||||
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
// 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.
|
// round trip on a local pipe rather than the whole window below.
|
||||||
if (AskTheRunningOneToComeForward(paths))
|
if (AskTheRunningOneToComeForward(paths, link))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -142,25 +176,32 @@ internal static class SingleInstance
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="paths">The profile this instance claimed.</param>
|
/// <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="comeForward">What to do when another launch asks for the window.</param>
|
||||||
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
|
/// <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(paths);
|
||||||
ArgumentNullException.ThrowIfNull(comeForward);
|
ArgumentNullException.ThrowIfNull(comeForward);
|
||||||
|
|
||||||
var listener = new Doorbell(PipeNameFor(paths), comeForward);
|
var listener = new Doorbell(PipeNameFor(paths), comeForward, openLink);
|
||||||
|
|
||||||
listener.Start();
|
listener.Start();
|
||||||
|
|
||||||
return listener;
|
return listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
|
/// <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>
|
/// <returns>Whether anything was listening.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
/// 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.
|
/// and proves itself, and this is the part with a protocol in it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
|
internal static bool AskTheRunningOneToComeForward(ClientPaths paths, SpelunkLink? link = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
@@ -175,7 +216,9 @@ internal static class SingleInstance
|
|||||||
|
|
||||||
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
||||||
|
|
||||||
writer.WriteLine(ComeForward);
|
// 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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -188,6 +231,42 @@ internal static class SingleInstance
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
||||||
@@ -278,14 +357,16 @@ internal static class SingleInstance
|
|||||||
{
|
{
|
||||||
private readonly string name;
|
private readonly string name;
|
||||||
private readonly Action comeForward;
|
private readonly Action comeForward;
|
||||||
|
private readonly Action<SpelunkLink>? openLink;
|
||||||
private readonly CancellationTokenSource lifetime = new();
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
|
|
||||||
private Task? loop;
|
private Task? loop;
|
||||||
|
|
||||||
internal Doorbell(string name, Action comeForward)
|
internal Doorbell(string name, Action comeForward, Action<SpelunkLink>? openLink)
|
||||||
{
|
{
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.comeForward = comeForward;
|
this.comeForward = comeForward;
|
||||||
|
this.openLink = openLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void Start() => loop = RunAsync(lifetime.Token);
|
internal void Start() => loop = RunAsync(lifetime.Token);
|
||||||
@@ -324,11 +405,16 @@ internal static class SingleInstance
|
|||||||
// leaveOpen, because this server outlives the conversation. See the remark above.
|
// leaveOpen, because this server outlives the conversation. See the remark above.
|
||||||
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
||||||
{
|
{
|
||||||
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
var said = await ReadBoundedLineAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
if (TryRead(said, out var link))
|
||||||
{
|
{
|
||||||
comeForward();
|
comeForward();
|
||||||
|
|
||||||
|
if (link is not null)
|
||||||
|
{
|
||||||
|
openLink?.Invoke(link);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +434,39 @@ internal static class SingleInstance
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ namespace DodoSSH.Client.App.Platform;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class DesktopDeviceKeyStores
|
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="paths">Where a stored key lives.</param>
|
||||||
/// <param name="askForPin">
|
/// <param name="askForPin">
|
||||||
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
|
/// 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(paths);
|
||||||
ArgumentNullException.ThrowIfNull(askForPin);
|
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())
|
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||||
{
|
{
|
||||||
return new WindowsDeviceKeyStore(paths);
|
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
|
/// 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
|
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice
|
||||||
/// their gesture had stopped working.
|
/// 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>
|
/// </remarks>
|
||||||
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
||||||
|
|
||||||
@@ -133,11 +149,22 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
|
|
||||||
private readonly ClientPaths paths;
|
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>
|
/// <summary>Creates the store.</summary>
|
||||||
public WindowsDeviceKeyStore(ClientPaths paths)
|
public WindowsDeviceKeyStore(ClientPaths paths)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
|
keyName = DeviceKeyNames.For(paths, KeyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -182,7 +209,7 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
ReadOnlyMemory<byte> devicePrivateKey,
|
ReadOnlyMemory<byte> devicePrivateKey,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var key = OpenOrCreate();
|
using var key = OpenOrCreate(keyName);
|
||||||
using var rsa = new RSACng(key);
|
using var rsa = new RSACng(key);
|
||||||
|
|
||||||
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
|
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)
|
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.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 />
|
/// <inheritdoc />
|
||||||
@@ -215,9 +248,11 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
File.Delete(paths.DeviceKeyFile);
|
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();
|
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
|
/// user and all have the same remedy, so none of them are worth telling apart here — see
|
||||||
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static byte[]? Unseal(byte[] sealedKey)
|
private static byte[]? Unseal(byte[] sealedKey, string name)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
if (!CngKey.Exists(name, new CngProvider(PlatformProvider)))
|
||||||
{
|
{
|
||||||
return null;
|
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);
|
using var rsa = new RSACng(key);
|
||||||
|
|
||||||
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
|
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
|
/// 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.
|
/// silently is the one behaviour this whole file exists to prevent.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static CngKey OpenOrCreate()
|
private static CngKey OpenOrCreate(string name)
|
||||||
{
|
{
|
||||||
var provider = new CngProvider(PlatformProvider);
|
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(
|
return CngKey.Create(
|
||||||
CngAlgorithm.Rsa,
|
CngAlgorithm.Rsa,
|
||||||
KeyName,
|
name,
|
||||||
new CngKeyCreationParameters
|
new CngKeyCreationParameters
|
||||||
{
|
{
|
||||||
Provider = provider,
|
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
|
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>
|
/// <summary>
|
||||||
/// Entry point.
|
/// Entry point.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -29,11 +39,12 @@ internal static class Program
|
|||||||
// and a silent installer run that reached either would be a background process holding the cache
|
// 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.
|
// 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,
|
// There are deliberately no OnFirstRun or OnAfterUpdate hooks that touch the profile. A hook process
|
||||||
// so the cache is bytes it cannot read, and the one thing that would want doing after an update —
|
// has no passphrase, so the cache is bytes it cannot read, and the one thing that would want doing
|
||||||
// a schema migration — already runs on every ordinary launch from MainWindowViewModel.StartAsync,
|
// after an update — a schema migration — already runs on every ordinary launch from
|
||||||
// before unlock and touching no encrypted content.
|
// MainWindowViewModel.StartAsync, before unlock and touching no encrypted content. The hooks it does
|
||||||
VelopackApp.Build().Run();
|
// have touch only the registry; see RunVelopack.
|
||||||
|
RunVelopack();
|
||||||
|
|
||||||
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||||
|
|
||||||
@@ -51,16 +62,103 @@ internal static class Program
|
|||||||
*/
|
*/
|
||||||
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||||
|
|
||||||
using var claim = SingleInstance.Claim(paths);
|
// 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)
|
if (claim is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchLink = link;
|
||||||
|
|
||||||
|
// After the claim, so a second launch that only hands its link over writes nothing.
|
||||||
|
CrashLog.Install(paths);
|
||||||
|
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
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>
|
/// <summary>
|
||||||
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
|
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
|
||||||
/// </summary>
|
/// </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">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
@@ -12,5 +13,10 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class ConfirmDeleteCard : UserControl
|
internal sealed partial class ConfirmDeleteCard : UserControl
|
||||||
{
|
{
|
||||||
public ConfirmDeleteCard() => InitializeComponent();
|
public ConfirmDeleteCard()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
SafeAnswerFocus.Follow(this, nameof(VaultViewModel.PendingDeletion), () => CancelButton);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
Width="420" SizeToContent="Height"
|
Width="420" SizeToContent="Height"
|
||||||
CanResize="False" ShowInTaskbar="False"
|
CanResize="False" ShowInTaskbar="False"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
Background="{StaticResource Surface}"
|
Background="{StaticResource Panel}"
|
||||||
Title="DodoSSH">
|
Title="DodoSSH">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
|
<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" />
|
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>
|
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
|
||||||
public DevicePinWindow()
|
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;
|
Accept.Click += OnAccept;
|
||||||
Cancel.Click += OnCancel;
|
Cancel.Click += OnCancel;
|
||||||
|
|||||||
@@ -253,8 +253,20 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
|
||||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
|
<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>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:HetznerRowViewModel">
|
<DataTemplate x:DataType="vm:HetznerRowViewModel">
|
||||||
<Border Classes="importrow" CornerRadius="8">
|
<Border Classes="importrow" CornerRadius="8">
|
||||||
@@ -291,7 +303,6 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
xmlns:dom="using:DodoSSH.Client.Domain"
|
||||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||||
x:Class="DodoSSH.Client.App.Views.HostDrawer"
|
x:Class="DodoSSH.Client.App.Views.HostDrawer"
|
||||||
x:DataType="vm:VaultViewModel">
|
x:DataType="vm:VaultViewModel">
|
||||||
@@ -385,6 +386,33 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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"
|
<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." />
|
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">
|
<Grid ColumnDefinitions="*,8,78">
|
||||||
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
|
<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}"
|
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
||||||
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
||||||
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
||||||
@@ -571,6 +600,7 @@
|
|||||||
-->
|
-->
|
||||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||||
|
IsEnabled="{Binding EditorAsksForLogin}"
|
||||||
HorizontalAlignment="Stretch" Height="40">
|
HorizontalAlignment="Stretch" Height="40">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||||
@@ -584,6 +614,15 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</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
|
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
|
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
||||||
@@ -664,6 +703,66 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
||||||
key is refused outright with no way to continue past it — so a legitimately rebuilt server
|
key is refused outright with no way to continue past it — so a legitimately rebuilt server
|
||||||
@@ -740,6 +839,84 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- ============ THE GROUP EDITOR ============ -->
|
<!-- ============ 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
|
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.
|
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>
|
<Panel>
|
||||||
|
|
||||||
<!-- ============ UNKNOWN HOST KEY ============ -->
|
<!-- ============ UNKNOWN HOST KEY ============ -->
|
||||||
@@ -83,7 +87,7 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||||
Command="{Binding TrustHostKeyCommand}" />
|
Command="{Binding TrustHostKeyCommand}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -119,7 +123,7 @@
|
|||||||
is not an answer, because this half of the control has none to give. Compare the accent button
|
is not an answer, because this half of the control has none to give. Compare the accent button
|
||||||
above, which connects.
|
above, which connects.
|
||||||
-->
|
-->
|
||||||
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
<Button x:Name="CloseButton" Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
||||||
Command="{Binding RejectHostKeyCommand}" />
|
Command="{Binding RejectHostKeyCommand}" />
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
internal sealed partial class HostKeyCard : UserControl
|
internal sealed partial class HostKeyCard : UserControl
|
||||||
{
|
{
|
||||||
public HostKeyCard() => InitializeComponent();
|
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;
|
||||||
}
|
}
|
||||||