Public Access
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e6bdb75db | ||
|
|
df64012865 | ||
|
|
8543c69cca | ||
|
|
88bd924d83 | ||
|
|
abd7e56bb5 | ||
|
|
57d3d3c801 | ||
|
|
e0509c6679 | ||
|
|
6f6e92f315 | ||
|
|
ef365f462e | ||
|
|
f7354aa7e8 | ||
|
|
d71f057bde | ||
|
|
36b6ded63e | ||
|
|
fa85ad1eff | ||
|
|
0cecf1afc4 | ||
|
|
089de72834 | ||
|
|
f43436218a | ||
|
|
3c8c0980c2 | ||
|
|
5b2e3f3651 | ||
|
|
988248fa57 | ||
|
|
902761d37b | ||
|
|
509937c4b8 | ||
|
|
5b32c76c95 | ||
|
|
6806e542f9 |
No files matched your search
@@ -1632,6 +1632,112 @@ jobs:
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
run: docker logout "$REGISTRY"
|
||||
|
||||
# Rolls ssh.dodotech.cloud onto the image the job above just pushed, by calling the deploy hook of the
|
||||
# Spelunk stack that runs it. Without this nothing told the server a new image existed, so a commit with
|
||||
# a migration in it reached the registry and stopped there, and the schema never moved.
|
||||
#
|
||||
# The migration itself is applied by the new container as it starts — Database:AutoMigrate, see
|
||||
# Persistence.MigrateDodoDatabaseAsync — under an advisory lock, before its port opens. So triggering
|
||||
# the deploy is what applies it, and the wait below is what proves it: /healthz/ready is unhealthy for
|
||||
# as long as any migration the running image knows about is pending, and an image whose migration
|
||||
# failed does not start at all.
|
||||
#
|
||||
# main only, like the :main tag the stack follows. A failure here fails the run, unlike Spelunk's own
|
||||
# trigger: a server left behind its schema is the outage this job exists to prevent, and a green build
|
||||
# over one is the quiet version of it.
|
||||
#
|
||||
# SPELUNK_DEPLOY_TOKEN is a repository secret (Settings -> Actions -> Secrets), sent as a header so it
|
||||
# stays out of every proxy and runner log a URL would be written to.
|
||||
deploy:
|
||||
name: deploy ssh.dodotech.cloud
|
||||
needs: [image]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: [linux]
|
||||
timeout-minutes: 15
|
||||
# A newer push to main cancels this run like any other (see concurrency at the top). That is safe:
|
||||
# once the hook has answered, the rollout and its migration happen on the server, and the newer
|
||||
# run's own deploy job does the waiting instead.
|
||||
env:
|
||||
DEPLOY_HOOK: https://api.spelunk.dodotech.cloud/api/hooks/deploy
|
||||
SERVER: https://ssh.dodotech.cloud
|
||||
steps:
|
||||
# See the desktop job's step of the same name.
|
||||
- name: ensure curl
|
||||
run: |
|
||||
set -eu
|
||||
SUDO=""
|
||||
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "Installing curl"
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
$SUDO apk add --no-cache curl
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y --no-install-recommends curl
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
$SUDO dnf install -y curl
|
||||
else
|
||||
echo "No apk, apt-get or dnf here, so curl cannot be installed from inside the job." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
curl --version | head -1
|
||||
|
||||
- name: trigger the deploy
|
||||
env:
|
||||
SPELUNK_DEPLOY_TOKEN: ${{ secrets.SPELUNK_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
if [ -z "${SPELUNK_DEPLOY_TOKEN:-}" ]; then
|
||||
echo "SPELUNK_DEPLOY_TOKEN is empty or unset, so the server was not told about this image." >&2
|
||||
echo "Add it under Settings -> Actions -> Secrets (not Variables)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
code="$(curl -sS -o "$RUNNER_TEMP/deploy-response" -w '%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $SPELUNK_DEPLOY_TOKEN" \
|
||||
"$DEPLOY_HOOK" || echo 000)"
|
||||
|
||||
case "$code" in
|
||||
200|202|204) echo "Deploy hook accepted (HTTP $code)." ;;
|
||||
401|404)
|
||||
echo "Deploy hook rejected the token (HTTP $code): revoked or regenerated, or the stack is gone." >&2
|
||||
exit 1 ;;
|
||||
409)
|
||||
echo "Deploy hook refused the token (HTTP 409): it is limited to a service the stack no longer has." >&2
|
||||
exit 1 ;;
|
||||
*)
|
||||
echo "Deploy hook answered HTTP $code:" >&2
|
||||
cat "$RUNNER_TEMP/deploy-response" >&2 || true
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
# Ten minutes, polled every ten seconds. A rollout restarts the container, so a few failed probes
|
||||
# in the middle are expected; what fails the job is never becoming ready again — a migration that
|
||||
# is still pending, one that crashed the start, or a stack holding the deploy for its schedule.
|
||||
- name: wait for the server to be ready
|
||||
run: |
|
||||
set -eu
|
||||
deadline=$(( $(date +%s) + 600 ))
|
||||
last=""
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
sleep 10
|
||||
last="$(curl -sS -m 10 -w ' (HTTP %{http_code})' "$SERVER/healthz/ready" 2>&1 || true)"
|
||||
case "$last" in
|
||||
"Healthy (HTTP 200)")
|
||||
echo "$SERVER is ready, with no migration pending."
|
||||
curl -sS -m 10 "$SERVER/api/v1/meta" || true
|
||||
echo
|
||||
exit 0 ;;
|
||||
esac
|
||||
echo "not ready yet: $last"
|
||||
done
|
||||
echo "$SERVER did not become ready within ten minutes. Last answer: $last" >&2
|
||||
echo "A pending migration is named in the container's log (PendingMigrations)." >&2
|
||||
exit 1
|
||||
|
||||
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
|
||||
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
||||
# install directory and profile — and the distance between those two sentences is the whole design. What
|
||||
|
||||
@@ -456,30 +456,40 @@ phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot
|
||||
|
||||
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.
|
||||
Spelunk's API address and the server's id in Spelunk. 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.
|
||||
- **Sign this device in to Spelunk once, under Settings → Security → Spelunk** (or with the button in a
|
||||
Spelunk host's editor). Give Spelunk's API address and press SIGN IN: your browser opens at Spelunk, you
|
||||
sign in there however you normally do — password, passkey, your company's single sign-on, second factor —
|
||||
and allow *DodoSSH on "<this device>"*. No key is pasted anywhere, and DodoSSH never sees your Spelunk
|
||||
password.
|
||||
- **One sign-in reaches every organisation you are in** on that Spelunk, as you, with the role you have on
|
||||
each server at the time: Admin opens a terminal, anything less is refused for that server alone. DodoSSH
|
||||
sends no organisation; Spelunk takes it from the server.
|
||||
- **It stays on this device.** Nothing about it is synced: each device signs in on its own, and the host
|
||||
itself carries only the address and the server id, so the same host opens on every device signed in to its
|
||||
Spelunk. The sign-in is sealed in the local cache like the remembered DodoSSH sign-in, and only ever sent to
|
||||
the Spelunk it came from. The API address must be `https://`, except for a Spelunk on this machine.
|
||||
- **It has no expiry, and ends with you.** Spelunk refuses it once you are removed, change your password or
|
||||
second factors, or sign it out under *Your account → Signed-in apps* there; DodoSSH then forgets it and asks
|
||||
you to sign in again. SIGN OUT beside it under Settings → Security ends it in both places, and so does
|
||||
signing out of DodoSSH.
|
||||
- **DodoSSH has to be online and signed in.** Before every connection through Spelunk it asks its identity
|
||||
provider whether your DodoSSH sign-in still stands (at most once a minute). An account the provider no longer
|
||||
signs in is signed out of DodoSSH and of every Spelunk on this device; a provider that cannot be reached
|
||||
holds the connection until it can.
|
||||
- **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.
|
||||
- **There is no falling back to a direct dial**, and a refusal is said in Spelunk's terms: signed out, not
|
||||
allowed on that server, a server Spelunk does not know, 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
|
||||
**Sign in through Spelunk (no SSH key needed)**, the last box 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;
|
||||
Ed25519 key pair in memory and sends the public half with the ticket request, which your sign-in 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,
|
||||
@@ -493,15 +503,14 @@ link, or hands the link to the copy already running, and **connects straight awa
|
||||
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.
|
||||
- Otherwise, when this device is signed in to that Spelunk, 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, 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 this device is not signed in to** opens the host editor instead, filled in the same
|
||||
way with sign-in ticked and the tag on, and offers to sign in to that Spelunk. Sign in, save, and every later
|
||||
link from that Spelunk connects straight away. A link can be sent by anything on this machine, so it never
|
||||
starts a sign-in by itself.
|
||||
|
||||
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.
|
||||
|
||||
@@ -640,6 +640,16 @@ go at 360dp:
|
||||
the membership test, the tab that lights and the back gesture's first case all read that one property.
|
||||
The desktop keeps its Keychain rail entry, so this is the second thing the two heads arrange
|
||||
deliberately differently, after the hub itself.
|
||||
|
||||
**And the 2026-10-04 redesign moved it back out.** The bar is VAULTS · CONNECTIONS · SETTINGS. The first
|
||||
entry is the open vault, with a HOSTS · KEYCHAIN · SNIPPETS switch under the vault's header, so the
|
||||
keychain and snippets sit beside the hosts they are used with rather than behind the hub (audit M8);
|
||||
`IsVaultSurface` lights it, and `IsMoreSurface` lost `Keychain` and `Snippets`. SETTINGS keeps what is
|
||||
about this phone and this account: an account card, security, vaults and the log, files, preferences.
|
||||
The strip of open shells above the bar went too — Connections carries the count in a badge instead. The
|
||||
keychain gained an add sheet and an editor page over the editors the shared view model already had, the
|
||||
files screen moved its seven buttons into a selection bar and an actions sheet, and a terminal's `+`
|
||||
sheet now opens SFTP on that terminal's own host.
|
||||
8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the
|
||||
same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never
|
||||
reflows a terminal while output is arriving.
|
||||
|
||||
+7
-3
@@ -300,7 +300,7 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
|
||||
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
|
||||
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
|
||||
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore,
|
||||
`17` SpelunkKey.
|
||||
`18` SpelunkSignIn. `17` is retired and never reused.
|
||||
|
||||
> **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
|
||||
@@ -319,8 +319,12 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
|
||||
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
|
||||
> `AadResourceTypeTests` pins each pairing by name for exactly that reason.
|
||||
|
||||
> **Added 2026-09-29:** `17` SpelunkKey, a Spelunk API key and the address it may be sent to. It is
|
||||
> `14` on the wire, three behind, as `16` ObjectStore is `13`.
|
||||
> **Added 2026-09-29, removed 2026-10-07:** `17` SpelunkKey, a Spelunk API key and the address it
|
||||
> may be sent to (`14` on the wire). Removed in beta when DodoSSH started signing in to Spelunk instead;
|
||||
> both numbers stay retired.
|
||||
|
||||
> **Added 2026-10-07:** `18` SpelunkSignIn, a device's sign-in to one Spelunk, sealed in the local cache
|
||||
> (§4.2's LocalCache AAD) and bound to its row. It is never synced and has no `SyncEntityType`.
|
||||
|
||||
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
|
||||
|
||||
|
||||
@@ -510,7 +510,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
|
||||
| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the VAULTS screen, where they are about something. |
|
||||
| `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. |
|
||||
| `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, and reads a typed `user@host[:port]` as a machine to connect to with a password, without saving it. The box says "search hosts, or type user@host", not "search hosts · run command". |
|
||||
| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. 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. |
|
||||
|
||||
+136
-15
@@ -322,6 +322,30 @@ closing the only open window ends the process, so the real window is shown while
|
||||
Nothing appearing at all means composition is running in the same dispatcher turn that has to paint the
|
||||
panel, which is what posting it at `DispatcherPriority.Background` prevents.
|
||||
|
||||
### 1.13 An emoji takes two columns, and the line after it stays where the shell thinks it is
|
||||
|
||||
Open a shell on a Linux host and run:
|
||||
|
||||
```
|
||||
printf '😀|\n12|\n日|\n'
|
||||
```
|
||||
|
||||
**Pass:** the three bars line up in one column. The emoji, the two digits and the CJK character each take two
|
||||
columns, which is what the remote counted.
|
||||
|
||||
Then type `echo 😀😀 hello` at the prompt without pressing Enter, press **←** five times, and type `X`.
|
||||
|
||||
**Pass:** the `X` appears between `he` and `llo`, which is where bash put it, and the line does not redraw over
|
||||
itself.
|
||||
|
||||
**What the tests already own.** `RendererUnicodeTests` proves the page loads `@xterm/addon-unicode11` and
|
||||
selects its table before the pane opens. What is left here is xterm itself drawing to that table.
|
||||
|
||||
**Failure means:** the emoji's bar sits one column left of the others, or the `X` lands a column away from the
|
||||
cursor. The pane is on xterm's default Unicode 6 widths, where an emoji is one column; check that
|
||||
`terminal.html` still loads `vendor/addon-unicode11.js` and that `AvaloniaTerminalAssetProvider` serves it.
|
||||
A script the provider does not list is a 404 the page never reports.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Known Hosts as its own page
|
||||
@@ -1364,6 +1388,39 @@ Ctrl+K, then click a result with the mouse rather than pressing Enter.
|
||||
|
||||
**Pass:** the palette closes and the connection starts.
|
||||
|
||||
### 7.10a Connecting to a machine that is not saved
|
||||
|
||||
Ctrl+K, type `user@host` for a reachable machine that is **not** on HOSTS, press Enter, type its password and
|
||||
press Enter again. Do it once against a machine whose host key this vault has never seen.
|
||||
|
||||
**Pass:** the palette asks for the password in place, with the caret already in the box. On the second Enter
|
||||
it closes, a tab named `user@host` opens, and the fingerprint card asks about the key; approving it connects.
|
||||
Afterwards HOSTS has nothing new on it, and the connection log has an entry for `user@host:22`. Escape from
|
||||
the password box goes back to the address with what was typed still in it.
|
||||
|
||||
**Failure means:** a new row on HOSTS is the palette saving what it promised not to. A tab that fails with
|
||||
"authentication failed" for a password known to be right is worth comparing against the phone's CONNECT TO A
|
||||
MACHINE box, which takes the same path — `VaultViewModel.ConnectToEndpointAsync` — so a difference between
|
||||
the two is in how the palette hands the password over, not in the connection. The headless half of this is
|
||||
`QuickConnectTests`; what it cannot do is dial.
|
||||
|
||||
### 7.10b Pressing Terminal with nothing open asks what to connect to
|
||||
|
||||
Close every terminal tab, then press **Terminal** on the rail.
|
||||
|
||||
**Pass:** the terminal's frame holds a prompt — `connect to:` with the caret already on it, so typing works
|
||||
without a click. Type part of a saved host's name: the matching hosts appear under the line, Tab completes to
|
||||
the first, and Enter connects to it — asking `user@host:port's password:` first only if that host has no key
|
||||
or saved password. Type `user@host` for a machine that is not saved instead: Enter asks for its password, and
|
||||
the second Enter opens a tab for it. Escape steps back from the password to the name. Afterwards HOSTS has
|
||||
nothing new on it, and no credential was saved — even with "remember" ticked in a host's drawer earlier.
|
||||
|
||||
**Failure means:** a prompt that needs a click before it takes typing is the terminal's WebView still holding
|
||||
the native keyboard focus — see `NativeKeyboardFocus`, which `ConnectPrompt.TakeKeyboard` calls. Anything drawn
|
||||
*under* a blank rectangle is the occlusion rule broken: the prompt may only show while no tab exists. A new
|
||||
credential in KEYS after connecting is the prompt's attempt acting on the drawer's tick, which
|
||||
`ShellFlowTests.ThePrompt_NeverSavesAPassword_EvenWithTheDrawersTickLeftOn` exists to stop.
|
||||
|
||||
### 7.11 A host or a bucket made now can be picked on FILES now
|
||||
|
||||
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
|
||||
@@ -1397,7 +1454,7 @@ time)" — is the worse failure of the two: it rebinds a host as a side effect o
|
||||
|
||||
## Phase 8 — Adding and removing on the phone's host list
|
||||
|
||||
Twenty-one checks, and the reason there are twenty-one rather than none is worth stating: **the layout suite
|
||||
Twenty-two checks, and the reason there are twenty-two rather than none is worth stating: **the layout suite
|
||||
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
||||
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
||||
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
||||
@@ -1770,6 +1827,23 @@ follow, and try disconnecting and reconnecting instead.
|
||||
|
||||
---
|
||||
|
||||
### 8.20 Connecting from the search box to a machine that is not saved
|
||||
|
||||
With a terminal already open on some other machine, go back to HOSTS and type `user@host` for a reachable
|
||||
machine that is **not** in the list into the search box at the top. Type its password into the card that
|
||||
appears, and press CONNECT.
|
||||
|
||||
**Pass:** the card appears only once there is an `@` in the box, names the address with `:22` on the end, and
|
||||
says NOT SAVED. CONNECT opens a second tab for `user@host` — the first is still there — and the fingerprint
|
||||
card asks about an unknown key over the terminal. Back on HOSTS the search box is empty, the list is the whole
|
||||
list again, and there is no new host in it. A port the parser refuses (`root@box:70000`) gives a sentence in
|
||||
amber under the box rather than a card.
|
||||
|
||||
**Failure means:** the card sitting *over* the list rather than pushing it down is the panels' arrangement
|
||||
having been lost — see the remark above `IsSendingChosenHostsToAVault` in `HostsScreen.axaml`. A password that
|
||||
shows in the keyboard's suggestion strip is `Classes="secret"` missing from the box. Everything behind the
|
||||
card is `ShellFlowTests`' hosts-search cases; this is the rectangles, and the software keyboard.
|
||||
|
||||
## Phase 9 — Tag chips and the picker
|
||||
|
||||
The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one
|
||||
@@ -2025,25 +2099,42 @@ that dialled it taken away. See `MainWindowViewModel.OnVaultConnectionFailed`.
|
||||
With at least one keychain host and one machine connected to by hand in the log, open Connections with
|
||||
nothing running.
|
||||
|
||||
**Pass:** tapping the keychain one lands on HOSTS with that host **ticked and the action bar up**, ready
|
||||
for CONNECT out of the ⋯. Tapping the hand-typed one puts its address back in the box on this screen and
|
||||
leaves the password box **empty**. Neither one dials anything on its own.
|
||||
**Pass:** tapping the keychain one puts **its name** on the `connect to:` line and carries on as typing that
|
||||
name would — its password line, or straight into a terminal for a host with a key or a saved password.
|
||||
Tapping the hand-typed one puts its `user@host:port` on the line and asks for its password with the password
|
||||
line **empty**. Neither one leaves this screen, and the hand-typed one dials nothing until a password is
|
||||
given.
|
||||
|
||||
**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should
|
||||
not have. If the password came back, something is storing it — it never was. And if the keychain one arrives
|
||||
at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through
|
||||
`AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press.
|
||||
**Failure means:** if the hand-typed one shows a host's name rather than its address, the log entry is
|
||||
carrying a host id it should not have. If the password came back, something is storing it — it never was.
|
||||
And if the keychain one shows the logged address rather than the host's name, the host id is not being
|
||||
matched against the keychain — see `MainWindowViewModel.ChoosePromptRecentAsync`.
|
||||
|
||||
### 11.9 Settings holds the keychain, and back walks out of it
|
||||
### 11.9 The keychain and snippets are the vault's, and back walks out of them
|
||||
|
||||
From HOSTS: Settings, then Keychain.
|
||||
From HOSTS, tap KEYCHAIN in the switch under the vault's header, then SNIPPETS.
|
||||
|
||||
**Pass:** the bottom bar has three entries and the Settings one stays lit while the keychain is showing.
|
||||
The keychain draws its own header with a back arrow — the vault name and sync light are not above it. Back,
|
||||
by gesture or by the arrow, returns to Settings and not to HOSTS; a second back returns to HOSTS.
|
||||
**Pass:** the bottom bar reads Vaults · Connections · Settings, and Vaults stays lit on all three. The
|
||||
vault's name, the sync light, the lock and the switch stay above each one; neither screen draws a header of
|
||||
its own. Back from KEYCHAIN or SNIPPETS returns to HOSTS; a second back leaves the application.
|
||||
|
||||
**Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first
|
||||
case, and those two have to move together — the switch mirrors that property by construction.
|
||||
**Failure means:** `IsVaultSurface` and the back gesture's first case have drifted apart — they list the same
|
||||
three screens and have to move together.
|
||||
|
||||
### 11.9b Every add is the floating button, and every confirm is on the right
|
||||
|
||||
On HOSTS, KEYCHAIN and SNIPPETS, and on SFTP once connected, there is a round + (an upload arrow on SFTP)
|
||||
at the bottom right, glowing without a square behind it. On the keychain it raises a sheet of five things
|
||||
to add; choosing ADD A PASSWORD opens a full-screen page with labels above its boxes. Tap a host that asks
|
||||
for a password.
|
||||
|
||||
**Pass:** the password sheet opens with the keyboard already up, its box ringed in violet; CANCEL is on the
|
||||
left and CONNECT on the right; the header and the bottom bar behind the sheet are dimmed, and tapping either
|
||||
closes the sheet. On SFTP, tapping a file puts a bar across the top with its name, a save arrow and ⋮; the ⋮
|
||||
sheet has Rename, Move, Permissions and a red Delete.
|
||||
|
||||
**Failure means:** a square behind a glowing button is `ClipToBounds` back on (`Phone.axaml`); a bright
|
||||
header under a sheet is `PhoneShell.ShowsSheetScrim` missing that sheet.
|
||||
|
||||
### 11.10 The accessory keys do not cost the terminal its keyboard · **needs a hardware keyboard**
|
||||
|
||||
@@ -2246,6 +2337,23 @@ Android is not delivering the click as a `contextmenu` event, and the gesture ne
|
||||
`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer
|
||||
has no right button to miss.
|
||||
|
||||
### 11.14 Connections with nothing open asks what to connect to
|
||||
|
||||
Close every shell, then press **Connections**. Type part of a saved host's name into the `connect to:` line.
|
||||
|
||||
**Pass:** the matching hosts appear under the line as buttons a thumb can hit; tapping one asks for that
|
||||
host's password on a line of its own under ssh's wording — or connects at once, for a host with a key or a
|
||||
saved password. Then type `user@host` for a machine that is not saved and press CONNECT: the password line
|
||||
appears with the keyboard already on it, and BACK — or the system back gesture — steps off it onto the
|
||||
address rather than leaving the screen. A tap on a RECENT row lands on the prompt and asks whatever it needs
|
||||
to before anything is dialled. Nothing new appears on HOSTS or in the keychain afterwards.
|
||||
|
||||
**Failure means:** a password line that appears with the keyboard still on the first line is the posted focus
|
||||
in `ConnectPrompt.axaml.cs` arriving before the box has bounds. Back that leaves Connections from the password
|
||||
line is `PhoneShell.OnBackRequested`'s prompt step having been reordered below the surface check. The
|
||||
decisions themselves — which name matches, what is refused, what is saved — are `ShellFlowTests`' prompt
|
||||
cases, shared with the desktop; this is the rectangles and the keyboard.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Shared vaults: the operations that span two accounts
|
||||
@@ -3430,3 +3538,16 @@ Register a device, sync something, then `sudo dnf remove dodossh`.
|
||||
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
|
||||
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
|
||||
something wrote into the install directory at runtime and would survive a reinstall.
|
||||
|
||||
### 19.13 A copy at the same path that rpm does not own is offered nothing
|
||||
|
||||
On a distribution without rpm — Arch, say, with a locally built package that puts the nightly tree at
|
||||
`/opt/DodoSSH.Nightly` — open PREFERENCES → UPDATES.
|
||||
|
||||
**Pass:** **CHECK NOW** is disabled and the row says this copy was installed outside the `dodossh-nightly`
|
||||
RPM and should be updated with the package manager that installed it. `~/.local/share/dodossh.nightly/updates/`
|
||||
stays empty however long it runs.
|
||||
|
||||
**Failure means:** a banner offering a nightly is the path being taken as proof the RPM is installed, or an
|
||||
rpm that cannot answer being read as an old version — the fault this check was written for, which offered
|
||||
every nightly as newer and fetched a 56 MB package the machine could never install, every six hours.
|
||||
@@ -139,7 +139,8 @@ EXPOSE 8080
|
||||
# without are DODOSSH_ConnectionStrings__Postgres, DODOSSH_Oidc__Authority and — on more
|
||||
# than one node — DODOSSH_Sync__CursorSigningKey.
|
||||
#
|
||||
# Nothing migrates the database from in here. That is the API's own design: it fails
|
||||
# /healthz/ready while a migration is pending and names it, so the schema is applied by
|
||||
# `dotnet ef database update` alongside the deployment rather than by a racing container.
|
||||
# The process migrates the database itself as it starts, before its port opens and under an
|
||||
# advisory lock so two replicas cannot race (Database:AutoMigrate, on by default). Leave it on
|
||||
# for ssh.dodotech.cloud: the CI deploy job relies on it. Turned off, /healthz/ready fails
|
||||
# while a migration is pending and names it, and something else has to apply the schema.
|
||||
ENTRYPOINT ["dotnet", "DodoSSH.Api.dll"]
|
||||
@@ -73,7 +73,6 @@ internal static class ItemKinds
|
||||
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
|
||||
new HostGroupKind(), new TagKind(), new SnippetKind(),
|
||||
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
|
||||
new SpelunkKeyKind(),
|
||||
}.ToDictionary(kind => kind.WireType);
|
||||
|
||||
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
|
||||
@@ -1094,98 +1093,3 @@ internal sealed class ObjectStoreKind : IItemKind
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
|
||||
/// <summary>Spelunk keys: an envelope and nothing else.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ObjectStoreKind"/>'s rules, because it holds the same class of thing: an API key is a password,
|
||||
/// and the address beside it is where somebody manages their servers. Nothing on the server calls Spelunk.
|
||||
/// </remarks>
|
||||
internal sealed class SpelunkKeyKind : IItemKind
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType WireType => SyncEntityType.SpelunkKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChangeEntityType ChangeType => ChangeEntityType.SpelunkKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IVaultItem?> FindAsync(
|
||||
DodoDbContext database,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await database.SpelunkKeys.SingleOrDefaultAsync(s => s.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
|
||||
DodoDbContext database,
|
||||
Guid vaultId,
|
||||
Guid[] ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.SpelunkKeys
|
||||
.Where(s => s.VaultId == vaultId && ids.Contains(s.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||
{
|
||||
var key = new VaultSpelunkKey { Id = id, VaultId = vaultId };
|
||||
|
||||
database.SpelunkKeys.Add(key);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>Refuses every plaintext field there is.</summary>
|
||||
/// <remarks>
|
||||
/// The relay fields are refused although this type holds an address, as they are for a bucket: the
|
||||
/// address belongs in the ciphertext, and the server has no business keeping a list of where its users
|
||||
/// manage their servers from.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public bool ValidateFields(SyncPlaintextFields fields, out string error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fields);
|
||||
|
||||
error = string.Empty;
|
||||
|
||||
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
|
||||
{
|
||||
error = "A Spelunk key is not something the server dials; its address stays encrypted.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
||||
{
|
||||
error = "A Spelunk key's contents are inside its payload.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
|
||||
{
|
||||
error = "A Spelunk key carries no plaintext fields at all.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
||||
/// <inheritdoc />
|
||||
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearFieldsOnDelete(IVaultItem item)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
@@ -56,7 +56,6 @@ internal sealed class TombstoneCollector(
|
||||
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);
|
||||
|
||||
|
||||
@@ -246,11 +246,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
knownHosts,
|
||||
deviceKeys,
|
||||
|
||||
// Difference 4: the system browser by intent, and the response by intent too rather than on a
|
||||
// loopback socket. See AndroidAuthorization for why the desktop's listener is not reused.
|
||||
async (url, cancellationToken) => await ServerConnection
|
||||
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
|
||||
.ConfigureAwait(false),
|
||||
SignInThroughBrowser(browser),
|
||||
|
||||
TimeProvider.System,
|
||||
connections,
|
||||
@@ -271,7 +267,9 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
copyToClipboard: copyToClipboard,
|
||||
readClipboard: readClipboard,
|
||||
|
||||
updates: updates);
|
||||
updates: updates,
|
||||
|
||||
spelunkAccounts: PhoneSpelunkAccounts(browser));
|
||||
|
||||
// Started rather than awaited: framework initialisation must not block on a schema migration. The
|
||||
// view model shows its own progress and handles its own failures.
|
||||
@@ -280,6 +278,23 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Difference 4: the system browser by intent, and the response by intent too rather than on a loopback
|
||||
/// socket. See AndroidAuthorization for why the desktop's listener is not reused.
|
||||
/// </summary>
|
||||
private static MainWindowViewModel.SignInHandler SignInThroughBrowser(AndroidBrowserLauncher browser) =>
|
||||
async (url, cancellationToken) => await ServerConnection
|
||||
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Signing in to Spelunk comes back the way signing in to DodoSSH does — by intent, to the same registered
|
||||
/// scheme, at the dev.dodotech.dodossh:/callback Spelunk allows — and names this phone as the account's
|
||||
/// device list does, for difference 5's reason.
|
||||
/// </summary>
|
||||
private static SpelunkAccounts PhoneSpelunkAccounts(AndroidBrowserLauncher browser) =>
|
||||
new(browser, path => new AndroidRedirectCallback(path), TimeProvider.System, PhoneEnvironment.DeviceName);
|
||||
|
||||
/// <summary>
|
||||
/// Difference 4: where the authorization response comes back to.
|
||||
/// </summary>
|
||||
|
||||
@@ -36,6 +36,156 @@
|
||||
this paragraph to learn it was deliberate.
|
||||
-->
|
||||
|
||||
<!--
|
||||
◆ FLUENT'S OWN BRUSHES, ANSWERED ONCE HERE RATHER THAN CONTROL BY CONTROL.
|
||||
|
||||
The 2026-10-02 audit (M5) found the controls this file never styled still drawing Fluent's defaults
|
||||
among the ones it did: a dropdown with a bright white border on a black fill beside 10-radius fields on
|
||||
#101019, a checkbox in Fluent's blue, a switch drawn as a bare circle, and a focused box whose fill went
|
||||
to pure black. Each of those reads its colours through a DynamicResource key, and a key defined here is
|
||||
found before the theme's own, because Avalonia searches an application's styles last-first and this file
|
||||
is included after FluentTheme. So one block of keys restyles every instance on the head — the ones on
|
||||
screens written later included — where a selector per control would restyle only the ones somebody
|
||||
remembered.
|
||||
|
||||
Every value is a palette brush, never a new colour. The focused border is the accent rather than a
|
||||
grey: a box with the keyboard up is the one control the user is currently writing into, and the
|
||||
redesign gives it the ring that says so.
|
||||
-->
|
||||
<Styles.Resources>
|
||||
<StaticResource x:Key="TextControlBackground" ResourceKey="Field" />
|
||||
<StaticResource x:Key="TextControlBackgroundPointerOver" ResourceKey="Field" />
|
||||
<StaticResource x:Key="TextControlBackgroundFocused" ResourceKey="Field" />
|
||||
<StaticResource x:Key="TextControlBorderBrush" ResourceKey="BorderMid" />
|
||||
<StaticResource x:Key="TextControlBorderBrushPointerOver" ResourceKey="BorderMid" />
|
||||
<StaticResource x:Key="TextControlBorderBrushFocused" ResourceKey="TerminalTabAccent" />
|
||||
<StaticResource x:Key="TextControlForegroundFocused" ResourceKey="Text" />
|
||||
<StaticResource x:Key="TextControlPlaceholderForeground" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="TextControlPlaceholderForegroundFocused" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="TextControlPlaceholderForegroundPointerOver" ResourceKey="TextGhost" />
|
||||
|
||||
<StaticResource x:Key="ComboBoxBackground" ResourceKey="Field" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundPointerOver" ResourceKey="Field" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundPressed" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundUnfocused" ResourceKey="Field" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundDisabled" ResourceKey="Raised" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrush" ResourceKey="BorderMid" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrushPointerOver" ResourceKey="BorderMid" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrushPressed" ResourceKey="TerminalTabAccent" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrushDisabled" ResourceKey="Border" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundBorderBrushFocused" ResourceKey="TerminalTabAccent" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundBorderBrushUnfocused" ResourceKey="BorderMid" />
|
||||
<StaticResource x:Key="ComboBoxForeground" ResourceKey="Text" />
|
||||
<StaticResource x:Key="ComboBoxForegroundFocused" ResourceKey="Text" />
|
||||
<StaticResource x:Key="ComboBoxForegroundFocusedPressed" ResourceKey="Text" />
|
||||
<StaticResource x:Key="ComboBoxDropDownGlyphForeground" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ComboBoxDropDownGlyphForegroundFocused" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ComboBoxDropDownGlyphForegroundFocusedPressed" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ComboBoxDropDownBackground" ResourceKey="Chip" />
|
||||
<StaticResource x:Key="ComboBoxDropDownBorderBrush" ResourceKey="BorderHover" />
|
||||
<StaticResource x:Key="ComboBoxItemBackgroundPointerOver" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ComboBoxItemBackgroundPressed" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ComboBoxItemBackgroundSelected" ResourceKey="Active" />
|
||||
<StaticResource x:Key="ComboBoxItemBackgroundSelectedPointerOver" ResourceKey="Active" />
|
||||
<StaticResource x:Key="ComboBoxItemBackgroundSelectedPressed" ResourceKey="Active" />
|
||||
<StaticResource x:Key="ComboBoxItemForegroundSelected" ResourceKey="AccentText" />
|
||||
<StaticResource x:Key="ComboBoxItemForegroundSelectedPointerOver" ResourceKey="AccentText" />
|
||||
<StaticResource x:Key="ComboBoxItemForegroundSelectedPressed" ResourceKey="AccentText" />
|
||||
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillUnchecked" ResourceKey="Field" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillUncheckedPointerOver" ResourceKey="Field" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillUncheckedPressed" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundStrokeUnchecked" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundStrokeUncheckedPointerOver" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundStrokeUncheckedPressed" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillChecked" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillCheckedPointerOver" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillCheckedPressed" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="CheckBoxCheckGlyphForegroundChecked" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="CheckBoxCheckGlyphForegroundCheckedPointerOver" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="CheckBoxCheckGlyphForegroundCheckedPressed" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="CheckBoxForegroundUnchecked" ResourceKey="Text" />
|
||||
<StaticResource x:Key="CheckBoxForegroundUncheckedPointerOver" ResourceKey="Text" />
|
||||
<StaticResource x:Key="CheckBoxForegroundUncheckedPressed" ResourceKey="Text" />
|
||||
<StaticResource x:Key="CheckBoxForegroundChecked" ResourceKey="Text" />
|
||||
<StaticResource x:Key="CheckBoxForegroundCheckedPointerOver" ResourceKey="Text" />
|
||||
<StaticResource x:Key="CheckBoxForegroundCheckedPressed" ResourceKey="Text" />
|
||||
|
||||
<StaticResource x:Key="ToggleSwitchFillOn" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchFillOnPointerOver" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchFillOnPressed" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOn" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOnPointerOver" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOnPressed" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOn" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOnPointerOver" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOnPressed" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="ToggleSwitchFillOff" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ToggleSwitchFillOffPointerOver" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ToggleSwitchFillOffPressed" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOff" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOffPointerOver" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOffPressed" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOff" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOffPointerOver" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOffPressed" ResourceKey="TextFaint" />
|
||||
</Styles.Resources>
|
||||
|
||||
<!--
|
||||
Touch has no hover, so a pointer-over fill is one that sticks after a tap until the next touch lands
|
||||
somewhere else — the grey square the audit found left behind on Vaults' refresh button (M5). The
|
||||
transparent shapes keep their resting fill under the pointer; pressed is the feedback a finger gets.
|
||||
-->
|
||||
<Style Selector="Button.icon:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.row:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ A selected row in a list is not lit. The hosts list says why in HostsScreen.axaml — a lit row would be
|
||||
a mark with no action behind it — but only its own Border.hostrow.chosen was ever changed, and Fluent's
|
||||
ListBoxItem:selected went on painting #3D486B, a slate blue the palette has never held, under whichever
|
||||
row had been tapped last (audit M3). Pointer-over goes with it for the reason the button keys above do.
|
||||
-->
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The dropdown, on TextBox.field's numbers so the two sit in one form as one kind of box: 44 tall, a
|
||||
10 radius, Field on BorderMid. The colours come from the keys above; these are the geometry Fluent sets
|
||||
as plain values.
|
||||
-->
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="14,0,10,0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A checkbox's caption on the box's own line. Fluent tops the content of a CheckBox taller than its box,
|
||||
so in a 44-pixel row the label sat below the tick's centre (audit M5).
|
||||
-->
|
||||
<Style Selector="CheckBox">
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Height" Value="48" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
@@ -46,6 +196,14 @@
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<!--
|
||||
◆ Off, because Fluent's Button theme turns it on, and the glow below is drawn outside the control by
|
||||
definition. With the clip on, the blur was cut at the button's square bounds and showed as a lighter
|
||||
rectangle behind the rounded fill on every primary button and the FAB (audit M1). Measured in the
|
||||
desktop's layout harness: ClipToBounds reads True under the theme, and cleared, the glow renders whole.
|
||||
The desktop's Button.accent carries the same setter for the same reason.
|
||||
-->
|
||||
<Setter Property="ClipToBounds" Value="False" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
@@ -86,6 +244,11 @@
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The alternative to a primary button, and in a row of the two it is on the LEFT: Android puts the action
|
||||
that confirms on the right, and every two-button row on this head had it the other way round until the
|
||||
2026-10-04 redesign (audit M12) — CANCEL | SAVE, KEEP | DELETE, CANCEL | CONNECT.
|
||||
-->
|
||||
<Style Selector="Button.secondary">
|
||||
<Setter Property="Height" Value="48" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
@@ -100,6 +263,22 @@
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<!--
|
||||
◆ Disabled is quieter than enabled, which Fluent had the wrong way round: with no rule here its #3C3C43
|
||||
disabled slab filled the outline, so SFTP's unavailable SAVE FILE was the heaviest button in its row
|
||||
(audit M4). The same flat-and-unlit treatment Button.primary:disabled gets, on an outline.
|
||||
-->
|
||||
<Style Selector="Button.secondary /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextGhost}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Height" Value="48" />
|
||||
@@ -324,6 +503,8 @@
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="24" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<!-- Off for the glow; see the remark on Button.primary's own setter, which is the same fix. -->
|
||||
<Setter Property="ClipToBounds" Value="False" />
|
||||
</Style>
|
||||
<!-- See the remark above Button.primary's own /template/ ContentPresenter rule: BoxShadow has no home on
|
||||
a Button itself, so the fill moves down here alongside it rather than staying a plain Setter. -->
|
||||
@@ -468,9 +649,9 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<!-- Sans since the 2026-10-04 redesign: a category is a word, "SSH keys 3", rather than an identifier. -->
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Name="Chip" Background="{TemplateBinding Background}"
|
||||
@@ -493,11 +674,88 @@
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
No segmented control either. The design draws one over the file listing for LOCAL / REMOTE, and there is
|
||||
no local side on a phone — see FilesScreen. The two-way choices that are real, the log's two logs and
|
||||
the keychain's categories, are chip rows, which is the same control at the same size without a well
|
||||
around it.
|
||||
◆ A segmented control, which this file used to argue against having.
|
||||
|
||||
The argument was that the one the v2 design drew — LOCAL / REMOTE over the file listing — had nothing to
|
||||
switch on a phone, and the choices that were real were chip rows. The 2026-10-04 redesign has a choice
|
||||
that is a segmented control in every sense: the open vault's HOSTS · KEYCHAIN · SNIPPETS, three equal,
|
||||
exclusive views of one thing, always all three, never scrolled. Chips would make it a filter; a well with
|
||||
one lit segment says it is where you are.
|
||||
|
||||
The well is Panel on BorderSubtle at 14, the lit segment Active at 10 — a step inside it on the ladder,
|
||||
as a button sits inside a card. Sans and 14, since these are words rather than identifiers.
|
||||
-->
|
||||
<Style Selector="Border.segmented">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Padding" Value="4" />
|
||||
</Style>
|
||||
<Style Selector="RadioButton.segment">
|
||||
<Setter Property="MinHeight" Value="40" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="Margin" Value="2,0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Background="{TemplateBinding Background}" CornerRadius="{TemplateBinding CornerRadius}"
|
||||
MinHeight="{TemplateBinding MinHeight}">
|
||||
<ContentPresenter Content="{TemplateBinding Content}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="RadioButton.segment:checked">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="RadioButton.segment:pressed">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The eye beside a secret box: outlined like the box it sits beside, lit while it is revealing. Lit with
|
||||
the accent ring the focused box wears, so "you can read this" is said in the same colour as "you are
|
||||
typing here".
|
||||
-->
|
||||
<Style Selector="ToggleButton.reveal">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.reveal /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.reveal:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
One Material icon: a codepoint of the embedded font the desktop draws its icons with, standing where a
|
||||
Unicode glyph stood (audit M7). Hidden from the accessibility tree — the codepoint reads as nothing, and the
|
||||
control around it carries the name — and it inherits its colour, so an icon in a button is the button's.
|
||||
-->
|
||||
<Style Selector="TextBlock.icon">
|
||||
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="AutomationProperties.AccessibilityView" Value="Raw" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBox.field">
|
||||
<Setter Property="Height" Value="44" />
|
||||
@@ -585,6 +843,30 @@
|
||||
<Setter Property="TextInputOptions.ContentType" Value="Password" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The Connections prompt's two lines, typed into the way a shell is: no fill and no border in any state, so
|
||||
the text sits on the pane where its label leaves off and the caret is the only sign the line takes input.
|
||||
44 tall all the same, which is this head's touch floor — the line has no box to aim at, so the whole
|
||||
height of it has to be the target. See ConnectPrompt.axaml. Declared after .secret, which it combines with
|
||||
on the password line and sets nothing in common with.
|
||||
-->
|
||||
<Style Selector="TextBox.prompt">
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.prompt /template/ Border#PART_BorderElement,
|
||||
TextBox.prompt:pointerover /template/ Border#PART_BorderElement,
|
||||
TextBox.prompt:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
|
||||
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
|
||||
@@ -657,25 +939,45 @@
|
||||
row's own height around — now say TextWrapping="NoWrap" beside their TextTrimming, which is the pairing
|
||||
that was always meant and was previously being had by accident.
|
||||
-->
|
||||
<!--
|
||||
◆ PROSE IS IN THE SANS FACE NOW, AND THAT IS THE RECORDED DECISION CATCHING UP RATHER THAN A NEW ONE.
|
||||
|
||||
docs/design-import-gaps.md ("The phone catches up") says the phone shares the desktop's faces, and on
|
||||
the desktop a hint, a caption and a settings title are Montserrat while the mono is kept for what is
|
||||
machine-shaped. These three still set MonoFont, so every paragraph on this head was JetBrains Mono —
|
||||
long, wide-set lines that the 2026-10-02 audit (M2) found tiring to read on the enrolment, settings and
|
||||
SFTP screens. They now take the application's default family, which is Montserrat (see
|
||||
DodoSshAndroidApplication.CustomizeAppBuilder).
|
||||
|
||||
One size step up with the change: Montserrat at the old mono sizes set noticeably smaller on the line,
|
||||
and 10-pixel captions were already at the floor. A block that shows an address, a path or a command
|
||||
rather than a sentence says Classes="detail code" at its own site — see TextBlock.detail.code below — so
|
||||
the split is still made where the content is known.
|
||||
-->
|
||||
<Style Selector="TextBlock.detail">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.body">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="LineHeight" Value="19" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.title">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<!--
|
||||
The other half of that split: a detail line that is an address, a path, a fingerprint fragment or a
|
||||
command. Combined with .detail rather than replacing it, so wrapping and colour stay the caption's.
|
||||
-->
|
||||
<Style Selector="TextBlock.detail.code">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A screen's own name, in the header. The one place this design is not monospaced: the design sets every
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.Android.Views"
|
||||
x:Class="DodoSSH.Client.Android.Views.ConnectPrompt"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The phone's half of the desktop's empty-terminal prompt, drawn on Connections while nothing is open: the
|
||||
same two lines, over the same view-model state — MainWindowViewModel.PromptText and its siblings — so a
|
||||
name or an address typed here is read, refused and dialled exactly as it is on the desktop. See the
|
||||
desktop's ConnectPrompt.axaml for the reasoning; what is here is only what a phone changes.
|
||||
|
||||
THREE THINGS A PHONE CHANGES.
|
||||
|
||||
No Tab and no Escape. The matches under the first line are buttons sized for a thumb instead of a
|
||||
completion list for a key, and a tap on one takes it outright — completing to a name and then pressing
|
||||
CONNECT is two taps for what one already says. The way off the password line is BACK beside CONNECT,
|
||||
and the system back gesture, which PhoneShell routes to the same command.
|
||||
|
||||
A CONNECT button, because the software keyboard's own return key may commit or may only close the
|
||||
keyboard — that is the IME's choice, as the files screen's GO button already says. Enter is still bound,
|
||||
for every keyboard that does send it.
|
||||
|
||||
The password's label on its own line. ssh's wording — "deploy@build.internal:2222's password:" — is a
|
||||
third of a phone's width before anything is typed, so the box goes under it rather than beside it.
|
||||
-->
|
||||
<Border Background="{StaticResource Pane}" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
CornerRadius="12" Padding="14,12">
|
||||
<StackPanel Spacing="6">
|
||||
|
||||
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
|
||||
Text="Type a saved host's name, or user@host[:port] for a machine that is not saved." />
|
||||
|
||||
<!-- ============ what to connect to ============ -->
|
||||
<Grid ColumnDefinitions="Auto,*" Margin="0,4,0,0">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="14" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
|
||||
AutomationProperties.Name="Connect to"
|
||||
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</Grid>
|
||||
|
||||
<!-- The saved hosts that match so far, best first. A tap takes one: it connects, or asks its password. -->
|
||||
<ItemsControl ItemsSource="{Binding PromptMatches}" IsVisible="{Binding ShowsPromptMatches}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Button Classes="chiptoggle" MinHeight="44" Margin="0,0,6,6"
|
||||
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="{Binding Label}" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- ============ the password, when one is needed ============ -->
|
||||
<StackPanel x:Name="PasswordLine" Spacing="0" IsVisible="{Binding IsPromptAskingForPassword}">
|
||||
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource Text}" TextWrapping="Wrap"
|
||||
Text="{Binding PromptPasswordLabel}" />
|
||||
<!--
|
||||
Classes="secret" for what it tells the software keyboard, not only for the dots: see Phone.axaml.
|
||||
-->
|
||||
<TextBox x:Name="Password" Classes="prompt secret" AutomationProperties.Name="Password"
|
||||
Text="{Binding PromptPassword}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
|
||||
|
||||
<!--
|
||||
CONNECT always; BACK only on the password line, where there is a line to go back from. Side by side,
|
||||
CONNECT on the right where the thumb ends up, the order the hosts screen's own panels use.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="BACK"
|
||||
Command="{Binding CancelPromptCommand}" IsVisible="{Binding IsPromptAskingForPassword}" />
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="CONNECT"
|
||||
Command="{Binding SubmitPromptCommand}" />
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,45 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>The phone's empty-terminal prompt: what to connect to, and its password when one is needed.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one thing the code-behind does is move the keyboard onto the password line when it appears. Enter on a
|
||||
/// name is answered with a second question, and a keyboard left on the first line — read-only by then — would
|
||||
/// type nothing and look as though it should.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It does not take the keyboard when the screen opens, unlike the desktop's prompt. On a phone that would
|
||||
/// raise the software keyboard over the recent connections underneath, which are the likelier tap.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>FindControl</c> rather than the generated <c>x:Name</c> fields, for the reason
|
||||
/// <c>HostsScreen</c>'s constructor gives: no view on this head calls <c>InitializeComponent</c>, so those
|
||||
/// fields are null at run time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class ConnectPrompt : UserControl
|
||||
{
|
||||
public ConnectPrompt()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
var line = this.FindControl<StackPanel>("PasswordLine")!;
|
||||
var box = this.FindControl<TextBox>("Password")!;
|
||||
|
||||
line.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == IsVisibleProperty && line.IsVisible)
|
||||
{
|
||||
// Posted at Loaded, as HostsScreen's own password sheet is: the box has no bounds on the turn
|
||||
// its line becomes visible, and focusing a control with none raises no keyboard.
|
||||
Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -56,16 +56,19 @@
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto,Auto">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto" Height="56" Margin="8,0"
|
||||
IsVisible="{Binding !HasRemoteBatch}">
|
||||
<!--
|
||||
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
|
||||
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
|
||||
somewhere the layout has removed. See PhoneShell.IsWide.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="icon" Content="←"
|
||||
<Button Grid.Column="0" Classes="icon" AutomationProperties.Name="Back"
|
||||
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}" />
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
|
||||
<TextBlock Grid.Column="1" Classes="heading" Text="SFTP" Margin="4,0"
|
||||
IsVisible="{Binding ShowsHostPicker}" />
|
||||
@@ -78,10 +81,59 @@
|
||||
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
|
||||
<Button Grid.Column="3" Classes="icon" Content="↻" Command="{Binding RefreshRemoteCommand}"
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
<!--
|
||||
◆ The other half of this host: a terminal on the machine whose files are showing. The redesign puts
|
||||
the two side by side as one session's two views; this application holds them as two connections,
|
||||
so what this does is the desktop's "Open terminal" — a new shell to the same host, through the same
|
||||
command the session sidebar uses. A bucket has no shell, so it is SFTP only.
|
||||
-->
|
||||
<Button Grid.Column="3" Classes="icon" AutomationProperties.Name="Open a terminal on this host"
|
||||
IsVisible="{Binding IsHostConnected}"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).OpenTerminalForFilesHostCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
<Button Grid.Column="4" Classes="icon" AutomationProperties.Name="Refresh"
|
||||
Command="{Binding RefreshRemoteCommand}" IsVisible="{Binding IsConnected}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
<!-- CLOSE was a button of its own in the block at the foot of the screen; it is the session's, so it
|
||||
is on the session's header. -->
|
||||
<Button Grid.Column="5" Classes="icon" AutomationProperties.Name="Close this session"
|
||||
Command="{Binding DisconnectCommand}" IsVisible="{Binding IsConnected}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ THE SELECTION BAR, in the header's row and in its place, as the hosts screen's action bar is.
|
||||
|
||||
The 2026-10-04 redesign's answer to the audit's M11, where seven buttons sat under the listing whether
|
||||
or not anything was chosen, and four of them answered a tap on nothing with a line in the status. Now
|
||||
the actions appear when there is something to act on: a tap on a file, or a tick. SAVE is on the bar
|
||||
because it is the one somebody reaches for; the four that change a file are behind the ⋮, in a sheet
|
||||
with room to say what each does. The ✕ lets go of the choice.
|
||||
-->
|
||||
<Border Grid.Row="0" Height="56" Background="{StaticResource Active}" Padding="4,0"
|
||||
IsVisible="{Binding HasRemoteBatch}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Button Grid.Column="0" Classes="icon" AutomationProperties.Name="Clear the selection"
|
||||
Command="{Binding ClearRemoteBatchCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" Foreground="{StaticResource Text}" />
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Margin="6,0" FontSize="16" FontWeight="Bold" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Text}" Text="{Binding RemoteBatchLabel}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="2" Classes="icon" AutomationProperties.Name="Save to this phone"
|
||||
IsEnabled="{Binding CanDownload}" Click="OnSaveFile">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" Foreground="{StaticResource Text}" />
|
||||
</Button>
|
||||
<Button Grid.Column="3" Classes="icon" AutomationProperties.Name="More actions"
|
||||
Command="{Binding OpenEntryMenuCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" Foreground="{StaticResource Text}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ opening one ============ -->
|
||||
<!--
|
||||
ShowsRemoteInvitation rather than !IsConnected, which stopped being the same question when a
|
||||
@@ -164,9 +216,9 @@
|
||||
<!-- ◆ In full, and wrapping rather than clipping. See the .fingerprint style. -->
|
||||
<SelectableTextBlock Classes="fingerprint" Text="{Binding PendingHostKey.Fingerprint}" />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="TRUST AND OPEN"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="TRUST AND OPEN"
|
||||
Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -225,12 +277,12 @@
|
||||
</Grid>
|
||||
|
||||
<!-- ============ where we are ============ -->
|
||||
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
||||
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
|
||||
Command="{Binding RemoteUpCommand}">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
|
||||
<Button Classes="row" MinHeight="40" Padding="9,0" CornerRadius="10"
|
||||
AutomationProperties.Name="Up one folder" Command="{Binding RemoteUpCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="18" Foreground="{StaticResource AccentText}" />
|
||||
</Button>
|
||||
<ItemsControl ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -241,7 +293,7 @@
|
||||
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="10"
|
||||
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||||
CommandParameter="{Binding Path}">
|
||||
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
|
||||
<TextBlock Classes="detail code" FontSize="11" Text="{Binding Name}" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
@@ -265,7 +317,7 @@
|
||||
answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as
|
||||
"this host has nothing pinned".
|
||||
-->
|
||||
<ScrollViewer Grid.Row="5" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
<ScrollViewer Grid.Row="5" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
|
||||
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -317,6 +369,8 @@
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Margin" Value="10,1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<!-- A name for TalkBack rather than the row view model's type name (audit H4). -->
|
||||
<Setter Property="AutomationProperties.Name" Value="{ReflectionBinding Name}" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
@@ -339,25 +393,33 @@
|
||||
<CheckBox Grid.Column="0" Width="40" MinWidth="0" VerticalAlignment="Center"
|
||||
IsChecked="{Binding IsChecked}" Click="OnRemoteRowChecked" />
|
||||
|
||||
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="▸"
|
||||
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
|
||||
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="·"
|
||||
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
|
||||
<!--
|
||||
◆ A folder looks like a folder now. It was a 12-pixel purple ▸, and a file a dot, which on a
|
||||
listing of names that are all set in the same face left a directory to be told apart by a
|
||||
glyph a thumb covers (audit M11). The tiles are the redesign's: the accent's wash for a place
|
||||
to go, a quieter one for a thing to take.
|
||||
-->
|
||||
<Border Grid.Column="1" Width="38" Height="38" CornerRadius="10"
|
||||
Background="{StaticResource AccentSoft}" IsVisible="{Binding IsNavigable}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="20" Foreground="{StaticResource AccentText}" />
|
||||
</Border>
|
||||
<Border Grid.Column="1" Width="38" Height="38" CornerRadius="10"
|
||||
Background="{StaticResource Hover}" IsVisible="{Binding IsFile}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="20" Foreground="{StaticResource TextFaint}" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="2" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
|
||||
<StackPanel Grid.Column="2" Margin="12,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="detail" Text="{Binding Size}"
|
||||
<TextBlock Classes="detail code" Text="{Binding Size}"
|
||||
IsVisible="{Binding Size, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<!-- Real POSIX bits from the server, not a plausible-looking mode. -->
|
||||
<TextBlock Classes="detail" Text="{Binding Permissions}" />
|
||||
<TextBlock Classes="detail code" Text="{Binding Permissions}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="detail" Text="{Binding Modified}"
|
||||
<TextBlock Grid.Column="3" Classes="detail code" Text="{Binding Modified}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -410,7 +472,7 @@
|
||||
<!-- ============ what to do with the chosen entry ============ -->
|
||||
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
|
||||
<Border Grid.Row="8" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="14,8">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
<!-- ◆ Counted, and it replaces the actions in place rather than stacking over the row it names. -->
|
||||
@@ -423,9 +485,9 @@
|
||||
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
||||
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE"
|
||||
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
|
||||
Command="{Binding ConfirmDeleteRemoteCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP"
|
||||
Command="{Binding CancelDeleteRemoteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -443,7 +505,7 @@
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteMove.Question}" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" Text="{Binding PendingRemoteMove.FullPath}" />
|
||||
<TextBlock Classes="detail code" TextWrapping="Wrap" Text="{Binding PendingRemoteMove.FullPath}" />
|
||||
<TextBox Classes="field" Text="{Binding RemoteMoveTarget}"
|
||||
PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
|
||||
<TextBox.KeyBindings>
|
||||
@@ -452,10 +514,10 @@
|
||||
</TextBox>
|
||||
<TextBlock Classes="body" Text="{Binding PendingRemoteMove.Note}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44"
|
||||
Content="{Binding PendingRemoteMove.ConfirmLabel}"
|
||||
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveRemoteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -521,10 +583,10 @@
|
||||
Text="{Binding PendingRemotePermissions.Preview}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="APPLY"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="APPLY"
|
||||
Command="{Binding ConfirmRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelRemotePermissionsCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -579,54 +641,13 @@
|
||||
IsVisible="{Binding HasTransfers}" Command="{Binding ClearCompletedCommand}" />
|
||||
|
||||
<!--
|
||||
◆ The two directions, on their own row above the pair below — because DELETE is the button on this
|
||||
screen that nothing can undo, and it must not sit at a thumb's width from the ones somebody presses
|
||||
often. ADD FILES is the primary of the two: it is the one that needs no selection, and the one this
|
||||
screen exists for on a phone.
|
||||
|
||||
SAVE FILE takes the selected row rather than several, and that asymmetry is the platform's: the
|
||||
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
||||
a file is selected and something is connected — and is reused rather than restated here.
|
||||
◆ The seven buttons that were here are gone, and every one of them went somewhere it is wanted:
|
||||
ADD FILES is the floating button over the listing, SAVE FILE is on the selection bar, RENAME, MOVE,
|
||||
PERMS and DELETE are in the sheet behind its ⋮, and CLOSE is on the header. The paragraph explaining
|
||||
the first two went with them, to the sheet's own SAVE row, where it is read at the moment it is true.
|
||||
What is left down here is what is genuinely about the session as a whole: the questions above, the
|
||||
queue, and the status.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
||||
IsEnabled="{Binding CanDownload}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ The two that put a file right rather than taking it away, above the row DELETE is on and away
|
||||
from it. Both open a form in place of these buttons — see the card above — because a phone has
|
||||
nowhere to put a text field except where something else already was.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="RENAME"
|
||||
Command="{Binding RenameRemoteCommand}" IsEnabled="{Binding CanRenameRemote}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
||||
Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ PERMS beside DELETE rather than on the row above, which is full: it is the fourth thing that can
|
||||
be done to a chosen row and the third that puts one right. Dark on a bucket, where an object has an
|
||||
access policy rather than nine bits — see CanChangeRemotePermissions.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="PERMS"
|
||||
Command="{Binding ChangeRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding CanChangeRemotePermissions}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CLOSE"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
<Panel Grid.Column="2" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
|
||||
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
||||
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||
Text="{Binding Status}" />
|
||||
@@ -634,6 +655,105 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ add ============ -->
|
||||
<!--
|
||||
◆ ADD FILES as the floating button, the one shape every add on this head takes. In the listing's row
|
||||
so it rides with the list rather than over the questions and the queue under it. Not while something
|
||||
is chosen: the bar across the top is then about that, and an add beside it would be a second subject.
|
||||
-->
|
||||
<Button Grid.Row="6" Classes="fab" Margin="0,0,18,18" HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
AutomationProperties.Name="Add files from this phone" Click="OnAddFiles">
|
||||
<Button.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<Binding Path="IsConnected" />
|
||||
<Binding Path="!HasRemoteBatch" />
|
||||
</MultiBinding>
|
||||
</Button.IsVisible>
|
||||
<TextBlock Classes="icon" Text="" FontSize="24" />
|
||||
</Button>
|
||||
|
||||
<!-- ============ ◆ what can be done to the chosen rows ============ -->
|
||||
<!--
|
||||
The add sheet's shape on the hosts screen, doing the files screen's job: a scrim, a bottom panel with
|
||||
its top corners rounded, dismissible because "which of these" has no wrong answer. DELETE is under a rule
|
||||
and in red — it was the one neutral DELETE on this head (audit M11) — and each entry is dark rather than
|
||||
gone when it cannot be done, so the four keep their places under a thumb.
|
||||
|
||||
Each entry also lowers the sheet as it runs: three of the four open a question in the space under the
|
||||
listing, and the sheet left over it would be the one thing in the way of answering.
|
||||
-->
|
||||
<Panel Grid.Row="0" Grid.RowSpan="9" IsVisible="{Binding IsEntryMenuOpen}">
|
||||
<Button Classes="scrim" Command="{Binding CloseEntryMenuCommand}" />
|
||||
|
||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
CornerRadius="22,22,0,0" Padding="12,10,12,16">
|
||||
<StackPanel>
|
||||
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,12" />
|
||||
|
||||
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold" Margin="12,0,12,8"
|
||||
Text="{Binding RemoteBatchLabel}" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Button Classes="row" IsEnabled="{Binding CanDownload}" Click="OnSaveFile"
|
||||
AutomationProperties.Name="Save to this phone">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Save to this phone" />
|
||||
<TextBlock Classes="detail" Text="Asks where first. The file is made there when you choose, so a transfer that fails leaves it empty." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding RenameRemoteCommand}" IsEnabled="{Binding CanRenameRemote}"
|
||||
Click="OnEntryMenuChosen" AutomationProperties.Name="Rename">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22" />
|
||||
<TextBlock Grid.Column="2" FontSize="14.5" FontWeight="SemiBold" Text="Rename"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}"
|
||||
Click="OnEntryMenuChosen" AutomationProperties.Name="Move">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22" />
|
||||
<TextBlock Grid.Column="2" FontSize="14.5" FontWeight="SemiBold" Text="Move"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ChangeRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding CanChangeRemotePermissions}" Click="OnEntryMenuChosen"
|
||||
AutomationProperties.Name="Permissions">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Permissions" />
|
||||
<TextBlock Classes="detail" Text="Read, write and execute for the owner, the group and everybody else." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Background="{StaticResource Border}" Margin="12,6" />
|
||||
|
||||
<Button Classes="row" Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}"
|
||||
Click="OnEntryMenuChosen" AutomationProperties.Name="Delete">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource DangerText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource DangerText}"
|
||||
Text="Delete" />
|
||||
<TextBlock Classes="detail" Text="Asks first, and says how many. There is no undo." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -41,6 +41,19 @@ internal sealed partial class FilesScreen : UserControl
|
||||
/// rather than of any row, and each listing replaces every row it has. See
|
||||
/// <c>TransfersViewModel.RemoteChecksChanged</c>.
|
||||
/// </remarks>
|
||||
/// <summary>An entry in the actions sheet was chosen: the sheet goes down as its command runs.</summary>
|
||||
/// <remarks>
|
||||
/// Three of the four open a question under the listing, and the sheet left over it would be the one thing
|
||||
/// in the way of answering. The command is bound in the markup; this only lowers the sheet.
|
||||
/// </remarks>
|
||||
private void OnEntryMenuChosen(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
{
|
||||
transfers.IsEntryMenuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) =>
|
||||
(DataContext as TransfersViewModel)?.RemoteChecksChanged();
|
||||
|
||||
@@ -116,6 +129,11 @@ internal sealed partial class FilesScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
// From the actions sheet as well as the bar, and the sheet goes down either way: the save picker is
|
||||
// about to cover the screen, and coming back to the sheet still up would be coming back to a question
|
||||
// already answered.
|
||||
transfers.IsEntryMenuOpen = false;
|
||||
|
||||
if (transfers.SelectedRemoteEntry is not { IsFile: true } row)
|
||||
{
|
||||
transfers.Status = "Choose a file on the host to save.";
|
||||
|
||||
@@ -67,21 +67,57 @@
|
||||
held as ids rather than as rows, see VaultViewModel.chosenHostIds — so searching for the second half
|
||||
of a selection and ticking it is a thing this screen supports rather than merely survives.
|
||||
-->
|
||||
<Border Grid.Row="0" Margin="14,10,14,4" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="10" Height="44">
|
||||
<Border Grid.Row="0" Margin="16,10,16,4" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12" Height="46">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Text="⌕" Foreground="{StaticResource TextFaint}" FontSize="13"
|
||||
Margin="13,0,0,0" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts"
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" Foreground="{StaticResource TextFaint}"
|
||||
FontSize="20" Margin="12,0,0,0" />
|
||||
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts, or type user@host"
|
||||
Padding="9,0" VerticalContentAlignment="Center"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}" FontSize="12" />
|
||||
Foreground="{StaticResource Text}" FontSize="14" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ sync + conflicts ============ -->
|
||||
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7">
|
||||
|
||||
<!--
|
||||
◆ A MACHINE THAT IS NOT SAVED. When the search box holds user@host, this card offers to connect to it
|
||||
with a password, and nothing is written to the keychain — the desktop palette's typed-address row, in
|
||||
this screen's shape. The terminal screen's own box does the same but is drawn only while nothing is
|
||||
open; this one is here whatever is running. See VaultViewModel.FilterAddress.
|
||||
|
||||
Above the list rather than over it, like the panels below: the hosts that also match what was typed
|
||||
stay in view, and one of them may be the same machine with a key already bound to it.
|
||||
|
||||
Classes="field secret" on the password for the reason the terminal screen's box gives: the class is
|
||||
what keeps the password out of the software keyboard's dictionary, not only off the screen.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasFilterAddress}" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12" Padding="13,11">
|
||||
<StackPanel Spacing="9">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding FilterAddressText}" FontSize="13"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NOT SAVED" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<TextBox Classes="field secret" Text="{Binding FilterAddressPassword}" PlaceholderText="password">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConnectToFilterAddressCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Classes="primary" Height="44" Content="CONNECT"
|
||||
Command="{Binding ConnectToFilterAddressCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Why what was typed is not an address yet, or why CONNECT refused — a sentence about the box. -->
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding FilterAddressRefusal}" IsVisible="{Binding HasFilterAddressRefusal}" />
|
||||
|
||||
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
|
||||
|
||||
<!--
|
||||
@@ -181,11 +217,11 @@
|
||||
Text="{Binding ChosenBindingToBringNote}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44"
|
||||
Content="{Binding ChosenHostVaultPanelVerb}"
|
||||
Command="{Binding ConfirmSendChosenHostsToAVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelSendChosenHostsToAVaultCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -224,9 +260,9 @@
|
||||
Text="A group lends its port, its account and its key to every host filed under it that says nothing itself, so this can change what these machines dial. Nothing else about them moves." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="FILE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="FILE"
|
||||
Command="{Binding ConfirmRegroupChosenHostsCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelRegroupChosenHostsCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -250,9 +286,9 @@
|
||||
Text="{Binding PendingDeletion.Usage}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE"
|
||||
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
|
||||
Command="{Binding ConfirmDeleteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
Command="{Binding CancelDeleteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -291,9 +327,9 @@
|
||||
</CheckBox>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE"
|
||||
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
|
||||
Command="{Binding ConfirmDeleteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
Command="{Binding CancelDeleteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -338,9 +374,9 @@
|
||||
Text="The group goes with the groups inside it and every host filed under them, all re-encrypted with the other vault's key. Any group it is nested under stays behind, so it arrives at the top level, and the hosts' tags stay behind with it." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="MOVE"
|
||||
Command="{Binding ConfirmMoveGroupCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveGroupCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -374,11 +410,21 @@
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Margin" Value="10,1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Margin" Value="16,4" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
<!--
|
||||
A name for TalkBack. Without it the item announced its view model's type name —
|
||||
"DodoSSH.Client.Shell.ViewModels.HostRowViewModel" (audit H4). Both row kinds in this list carry a
|
||||
Label, and a reflection binding is what lets one setter serve the two of them.
|
||||
-->
|
||||
<Setter Property="AutomationProperties.Name" Value="{ReflectionBinding Label}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ A host is a card since the 2026-10-04 redesign: Panel on a hairline at the 16 radius, where it was a
|
||||
transparent row on the canvas. A list of machines is a list of things, each one a target, and a card
|
||||
says where one ends and the next begins without a rule between them.
|
||||
|
||||
◆ The ticked row, filled. On a Border inside the item rather than on the item's own selected state,
|
||||
because "ticked" is a property of the host and the list has no idea about it — the fill and the
|
||||
tick at the right-hand end are the same fact drawn twice, which is deliberate: a fill alone is
|
||||
@@ -386,11 +432,14 @@
|
||||
360dp row.
|
||||
-->
|
||||
<Style Selector="Border.hostrow">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
</Style>
|
||||
<Style Selector="Border.hostrow.chosen">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
|
||||
@@ -402,7 +451,7 @@
|
||||
the filter, which is what a heading over a filtered list has to say.
|
||||
-->
|
||||
<DataTemplate DataType="vm:SidebarGroupHeader">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="8,12,8,5">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="4,14,0,2">
|
||||
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
@@ -419,8 +468,9 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9"
|
||||
VerticalAlignment="Center" />
|
||||
<Border Grid.Column="2" Classes="tag outline" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Count}" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ The phone's only way to do anything to a group. The desktop reaches all three from a menu on
|
||||
@@ -435,25 +485,40 @@
|
||||
|
||||
Hidden on the ungrouped heading, which has no group behind it to act on.
|
||||
-->
|
||||
<Button Grid.Column="3" Classes="icon" FontSize="15" Content="⋯" Margin="4,0,0,0"
|
||||
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0"
|
||||
AutomationProperties.Name="{Binding Label, StringFormat='Group {0}: edit, move or delete'}"
|
||||
IsVisible="{Binding GroupId, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
Command="{Binding $parent[views:HostsScreen].((vm:VaultViewModel)DataContext).OpenGroupSheetCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="20" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- A host. -->
|
||||
<DataTemplate DataType="vm:HostRowViewModel">
|
||||
<Border Classes="hostrow" Classes.chosen="{Binding IsChosen}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" MinHeight="56" Margin="14,12">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" MinHeight="48" Margin="12,11">
|
||||
|
||||
<!-- Green means a terminal is open on this host right now. See the .dot style. -->
|
||||
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}" Width="8" Height="8"
|
||||
VerticalAlignment="Center" />
|
||||
<!--
|
||||
◆ The redesign's tile: a terminal mark on the accent's wash, with the live dot on its corner.
|
||||
Green still means one thing only — a terminal is open on this host right now, see the .dot
|
||||
style — and riding the tile it says which machine without spending a column on it.
|
||||
-->
|
||||
<Panel Grid.Column="0" Width="42" Height="42" VerticalAlignment="Center">
|
||||
<Border CornerRadius="12" Background="{StaticResource AccentSoft}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="21" Foreground="{StaticResource AccentText}" />
|
||||
</Border>
|
||||
<Border Width="13" Height="13" CornerRadius="7" Background="{StaticResource Panel}"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-3,-3"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<Ellipse Classes="dot live" Width="9" Height="9" />
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="3" VerticalAlignment="Center">
|
||||
<StackPanel Grid.Column="1" Margin="14,0,0,0" Spacing="4" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="14" FontWeight="SemiBold"
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="15" FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
|
||||
<TextBlock Text="{Binding VaultBadge}" />
|
||||
@@ -464,10 +529,10 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||
<TextBlock Classes="detail code" FontSize="11.5" Text="{Binding Address}"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
|
||||
<TextBlock Classes="detail" Text="·" />
|
||||
<TextBlock Classes="detail" Text="{Binding Authentication}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -499,8 +564,8 @@
|
||||
Background="{StaticResource LiveWash}">
|
||||
<TextBlock Text="live" Foreground="{StaticResource Live}" />
|
||||
</Border>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" IsVisible="{Binding !IsConnected}" />
|
||||
<TextBlock Grid.Column="2" Classes="icon" Text="" Foreground="{StaticResource TextGhost}"
|
||||
FontSize="22" IsVisible="{Binding !IsConnected}" />
|
||||
|
||||
<!--
|
||||
◆ The tick, in a column of its own at the far end rather than in place of the dot or the
|
||||
@@ -509,9 +574,8 @@
|
||||
choosing a host hide a fact about it. An Auto column takes no width while the mark is
|
||||
collapsed, so a list with nothing ticked is laid out exactly as it was.
|
||||
-->
|
||||
<TextBlock Grid.Column="3" Text="✓" Margin="10,0,0,0" FontSize="15"
|
||||
Foreground="{StaticResource AccentText}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsChosen}" />
|
||||
<TextBlock Grid.Column="3" Classes="icon" Text="" Margin="10,0,0,0" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsChosen}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
@@ -604,9 +668,9 @@
|
||||
Text="A host inside this group uses these wherever it says nothing itself. They are read at connect time rather than copied in, so changing one here changes every host that never overrode it." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="SAVE"
|
||||
Command="{Binding SaveGroupCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelGroupEditCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -628,10 +692,13 @@
|
||||
twelfth machine while eleven are ticked for deletion is worse, because it does something and the
|
||||
something is not what the screen is about.
|
||||
-->
|
||||
<Button Grid.Row="2" Classes="fab" Content="+" Margin="0,0,18,18"
|
||||
<Button Grid.Row="2" Classes="fab" Margin="0,0,18,18"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
AutomationProperties.Name="Add a host or a group"
|
||||
IsVisible="{Binding ShowsAddButton}"
|
||||
Command="{Binding OpenAddSheetCommand}" />
|
||||
Command="{Binding OpenAddSheetCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="26" />
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -646,9 +713,10 @@
|
||||
"Which kind of thing are you adding" has no wrong answer and no answer at all is one of them.
|
||||
|
||||
And it lives here rather than in PhoneShell. HostKeySheet sits up there because the files screen raises
|
||||
it too; nothing but this screen raises this. The cost is that the scrim covers the screen and not the
|
||||
64px nav bar underneath, so the four destinations stay lit and tappable — which for a menu is right:
|
||||
navigating away is a perfectly good way to decide not to add anything.
|
||||
it too; nothing but this screen raises this. The cost used to be that the scrim covered the screen and
|
||||
not the header or the nav bar, which stayed lit and tappable round every sheet (audit M12). PhoneShell
|
||||
now dims those two rows as well while any sheet here is up, and a tap on them lowers the sheet — see
|
||||
PhoneShell.ShowsSheetScrim.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsAddSheetOpen}">
|
||||
|
||||
@@ -670,7 +738,7 @@
|
||||
|
||||
<Button Classes="row" Margin="0,10,0,0" Command="{Binding NewHostCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="New host" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="New host" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="A machine to connect to, filed under a group if you want one." />
|
||||
</StackPanel>
|
||||
@@ -678,7 +746,7 @@
|
||||
|
||||
<Button Classes="row" Command="{Binding NewGroupCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="New group" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="New group" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="A heading for hosts, and the port, user and key they fall back to." />
|
||||
</StackPanel>
|
||||
@@ -742,19 +810,19 @@
|
||||
|
||||
<Button Classes="row" IsVisible="{Binding HasOneChosenHost}"
|
||||
Command="{Binding ConnectToChosenHostCommand}">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Text="Connect" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" IsVisible="{Binding HasOneChosenHost}"
|
||||
Command="{Binding BrowseChosenHostCommand}">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Text="Connect via SFTP" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding MoveChosenHostsToVaultCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Move to vault" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Move to vault" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Re-encrypted with the other vault's key. Groups and tags stay behind." />
|
||||
</StackPanel>
|
||||
@@ -762,19 +830,19 @@
|
||||
|
||||
<Button Classes="row" Command="{Binding CopyChosenHostsToVaultCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Copy to vault" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Copy to vault" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="A second copy over there, and these stay where they are." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding RegroupChosenHostsCommand}">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Text="Change group" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding DuplicateChosenHostsCommand}">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Text="Duplicate" />
|
||||
</Button>
|
||||
|
||||
@@ -782,7 +850,7 @@
|
||||
|
||||
<Button Classes="row" Command="{Binding DeleteChosenHostsCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Danger}" Text="Remove" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Asks first, and says how many. There is no undo." />
|
||||
@@ -816,34 +884,73 @@
|
||||
the box the next tap reads, and it would satisfy the length check that decides whether to raise this at
|
||||
all. See CancelConnectPassword.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsAskingForConnectPassword}">
|
||||
<Panel x:Name="PasswordSheet" IsVisible="{Binding IsAskingForConnectPassword}">
|
||||
|
||||
<Button Classes="scrim" Command="{Binding CancelConnectPasswordCommand}" />
|
||||
|
||||
<!--
|
||||
◆ The 2026-10-04 redesign's connect sheet. Three changes from the audit (M12, and the placeholder-as-
|
||||
label finding), and one from the redesign itself:
|
||||
|
||||
· CANCEL on the left and CONNECT on the right, which is the order Android puts a confirming action
|
||||
in — the sheet had them the other way round, and so did every two-button row on this head;
|
||||
· the box has a label over it, so what it is for is still on screen once something is typed;
|
||||
· it takes the focus as it opens — see HostsScreen.FocusThePasswordBox — so the keyboard is up
|
||||
without a second tap on the one box the sheet exists for;
|
||||
· the host is named at the top with its address under it, in the card shape the list draws it in,
|
||||
and a ✕ beside it closes the sheet as the scrim does.
|
||||
|
||||
The eye beside the box reveals what was typed, for the one case a phone keyboard makes common: a
|
||||
password typed blind on glass is a password typed wrong. It is TextBox.RevealPassword, so the secret
|
||||
is never copied anywhere to be shown.
|
||||
-->
|
||||
<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="10">
|
||||
CornerRadius="22,22,0,0" Padding="20,10,20,18">
|
||||
<StackPanel Spacing="12">
|
||||
|
||||
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,6" />
|
||||
HorizontalAlignment="Center" Margin="0,0,0,4" />
|
||||
|
||||
<!--
|
||||
It names the host, as the bar did and for the same reason: the row it was tapped on is behind a
|
||||
sheet and a scrim, and "which machine is this password for" is the one thing somebody about to
|
||||
type a secret has to be sure of.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="label" Text="CONNECT TO" />
|
||||
<TextBlock Classes="mono" FontSize="11" VerticalAlignment="Center"
|
||||
Text="{Binding SelectedHost.Label}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="Auto,12,*,Auto">
|
||||
<Border Grid.Column="0" Width="42" Height="42" CornerRadius="12"
|
||||
Background="{StaticResource AccentSoft}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="21" Foreground="{StaticResource AccentText}" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="15" FontWeight="SemiBold"
|
||||
Text="{Binding SelectedHost.Label}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail code" Text="{Binding SelectedHost.Address}"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="3" Classes="icon" AutomationProperties.Name="Close"
|
||||
Command="{Binding CancelConnectPasswordCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<TextBox Classes="field secret" Text="{Binding ConnectPassword}" PlaceholderText="password">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="label" Text="PASSWORD" />
|
||||
<Grid ColumnDefinitions="*,6,Auto">
|
||||
<TextBox x:Name="PasswordBox" Grid.Column="0" Classes="field secret" Height="48"
|
||||
Text="{Binding ConnectPassword}" PlaceholderText="the account's password"
|
||||
AutomationProperties.Name="Password">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<ToggleButton Grid.Column="2" Classes="reveal" Width="48" Height="48"
|
||||
AutomationProperties.Name="Show the password"
|
||||
IsChecked="{Binding #PasswordBox.RevealPassword}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="21" />
|
||||
</ToggleButton>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The tick is the phone's whole answer to storing a password, and on this head it is the only one:
|
||||
@@ -853,15 +960,15 @@
|
||||
quietly lives.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding RemembersConnectPassword}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
<TextBlock FontSize="13.5" TextWrapping="Wrap" Foreground="{StaticResource Text}"
|
||||
Text="Remember this password for this host" />
|
||||
</CheckBox>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="CONNECT"
|
||||
Command="{Binding ConnectCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Grid ColumnDefinitions="*,10,*">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="48" Content="CANCEL"
|
||||
Command="{Binding CancelConnectPasswordCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Height="48" Content="CONNECT"
|
||||
Command="{Binding ConnectCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
@@ -912,7 +1019,7 @@
|
||||
Command="{Binding EditGroupFromHeadingCommand}"
|
||||
CommandParameter="{Binding GroupSheet}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Edit" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Edit" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="Its name, where it sits, and the port, user and key its hosts fall back to." />
|
||||
</StackPanel>
|
||||
@@ -922,7 +1029,7 @@
|
||||
Command="{Binding MoveGroupFromHeadingCommand}"
|
||||
CommandParameter="{Binding GroupSheet}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||
Text="Move to another vault" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Takes the groups inside it and every host filed under them, re-encrypted with the other vault's key." />
|
||||
@@ -935,7 +1042,7 @@
|
||||
Command="{Binding DeleteGroupFromHeadingCommand}"
|
||||
CommandParameter="{Binding GroupSheet}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Danger}" Text="Delete" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Asks first, and asks separately whether the hosts filed under it go too." />
|
||||
@@ -1066,10 +1173,10 @@
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||
Text="Added to the keychain as soon as you press ADD, so it stays even if you leave this host without saving." />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Content="ADD" MinHeight="44"
|
||||
<Button Grid.Column="2" Classes="primary" Content="ADD" MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding AddEditorCredentialCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Content="CANCEL" MinHeight="44"
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL" MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding CancelEditorCredentialCommand}" />
|
||||
</Grid>
|
||||
@@ -1219,8 +1326,8 @@
|
||||
|
||||
<!--
|
||||
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.
|
||||
one unticks the other. No credential is chosen here: the host is reached as whoever this phone signed
|
||||
in to that Spelunk as, and a button signs in when it has not. See VaultViewModel.Spelunk.cs.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5"
|
||||
@@ -1232,16 +1339,13 @@
|
||||
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>
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||
Text="{Binding EditorSpelunkSignInNote}"
|
||||
IsVisible="{Binding EditorSpelunkSignInNote, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<Button Classes="secondary" MinHeight="44" HorizontalAlignment="Stretch"
|
||||
Content="SIGN IN TO SPELUNK"
|
||||
Command="{Binding SignInToEditorSpelunkCommand}"
|
||||
IsVisible="{Binding EditorSpelunkNeedsSignIn}" />
|
||||
<!-- The desktop card's sign-in box. See SpelunkRoute.SignInThroughSpelunk. -->
|
||||
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5"
|
||||
@@ -1250,7 +1354,7 @@
|
||||
</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." />
|
||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. You connect as yourself in Spelunk, which takes the Admin role on the server, and DodoSSH has to be online to check your account first. 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
|
||||
@@ -1265,9 +1369,9 @@
|
||||
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="SAVE"
|
||||
Command="{Binding SaveHostCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelEditCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
@@ -62,8 +63,30 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
InputElement.SetIsHoldingEnabled(rows, true);
|
||||
rows.Holding += OnRowHeld;
|
||||
|
||||
var sheet = this.FindControl<Panel>("PasswordSheet")!;
|
||||
var box = this.FindControl<TextBox>("PasswordBox")!;
|
||||
|
||||
sheet.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == IsVisibleProperty && sheet.IsVisible)
|
||||
{
|
||||
FocusThePasswordBox(box);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the focus, and so the keyboard, in the connect sheet's box as the sheet opens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The sheet exists for that one box, and it used to open with nothing focused — a second tap before
|
||||
/// typing could start (audit M12). Posted at <c>Loaded</c> priority because the box is not yet laid out
|
||||
/// on the turn the sheet's visibility changes, and focusing a control with no bounds raises no keyboard.
|
||||
/// </remarks>
|
||||
private static void FocusThePasswordBox(TextBox box) =>
|
||||
Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the row that was tapped, or ticks it where hosts are already ticked.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.Android.Views"
|
||||
xmlns:ssh="using:DodoSSH.Client.Ssh"
|
||||
x:Class="DodoSSH.Client.Android.Views.KeychainScreen"
|
||||
x:DataType="vm:VaultViewModel"
|
||||
Background="{StaticResource Canvas}">
|
||||
@@ -32,38 +33,42 @@
|
||||
This screen moved behind SETTINGS, so it draws its own header — the one every hub screen has. It is not
|
||||
a decoration: the shell's header stands down behind the hub, and without a row of its own there would be
|
||||
no back arrow and nothing on screen saying what this list is.
|
||||
|
||||
── 2026-10-04 redesign ───────────────────────────────────────────────────────────────────────────────
|
||||
And it moved back out, under VAULTS: the keychain is the open vault's contents as much as its hosts are,
|
||||
so it is the middle of the vault's HOSTS · KEYCHAIN · SNIPPETS switch, and the shell's header is over it
|
||||
again. The header below is now drawn only where that switch is not.
|
||||
-->
|
||||
|
||||
<Panel>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<!--
|
||||
The same shape as the one over PREFERENCES in PhoneShell, and pointed at the same place. Back goes to
|
||||
the hub rather than to HOSTS, because the hub is where this screen is reached from and back that
|
||||
skipped it would strand the four rows underneath it.
|
||||
◆ Wide surfaces only, since the 2026-10-04 redesign. On a narrow one this screen is the KEYCHAIN third of
|
||||
the open vault's switch, under the vault's own header — see PhoneShell.ShowsVaultTabs — and a second row
|
||||
saying "Keychain" under a lit KEYCHAIN segment would be the two rows of chrome this head avoids. Its back
|
||||
arrow went with it: back from here is the system gesture, which goes to the vault's hosts.
|
||||
|
||||
A wide surface has no switch — the rail lists the three — so the screen's own name stays there, without
|
||||
an arrow, for the reason it never had one there: the rail is how you leave.
|
||||
-->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0">
|
||||
<!--
|
||||
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
|
||||
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
|
||||
somewhere the layout has removed. See PhoneShell.IsWide.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="icon" Content="←"
|
||||
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Text="Keychain" Margin="4,0" />
|
||||
<Grid Grid.Row="0" Height="56" Margin="12,0" IsVisible="{Binding $parent[views:PhoneShell].IsWide}">
|
||||
<TextBlock Classes="heading" Text="Keychain" Margin="4,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ categories ============ -->
|
||||
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
<!-- Hidden rather than Auto: the row still scrolls, but Fluent's desktop scrollbar was drawn across the
|
||||
bottom of the chips themselves (audit M5). -->
|
||||
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled"
|
||||
Margin="0,12,0,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="16,0">
|
||||
|
||||
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsAll, Mode=OneWay}"
|
||||
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.All}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="ALL" />
|
||||
<TextBlock Text="All" />
|
||||
<TextBlock Text="{Binding TotalItemCount}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
@@ -71,7 +76,7 @@
|
||||
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsKeys, Mode=OneWay}"
|
||||
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.Keys}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="SSH KEYS" />
|
||||
<TextBlock Text="SSH keys" />
|
||||
<TextBlock Text="{Binding Keys.Count}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
@@ -80,7 +85,7 @@
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Credentials}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="PASSWORDS" />
|
||||
<TextBlock Text="Passwords" />
|
||||
<TextBlock Text="{Binding Credentials.Count}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
@@ -95,7 +100,7 @@
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Tags}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="TAGS" />
|
||||
<TextBlock Text="Tags" />
|
||||
<TextBlock Text="{Binding Tags.Count}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
@@ -104,7 +109,7 @@
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Buckets}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="BUCKETS" />
|
||||
<TextBlock Text="Buckets" />
|
||||
<TextBlock Text="{Binding ObjectStores.Count}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
@@ -134,35 +139,45 @@
|
||||
style below, which had nothing to colour until now. DELETE is the last button that should be pressed on
|
||||
a guess about which row it means.
|
||||
-->
|
||||
<Border Grid.Row="2" IsVisible="{Binding ShowsItemActions}" Margin="12,4"
|
||||
<!--
|
||||
◆ EDIT is here now, beside MOVE and DELETE, because there is something for it to open: the editor page at
|
||||
the foot of this file, which is the desktop's five editors drawn for a phone. The three sit in a row
|
||||
under the item's name rather than beside it, which is what fits at 360dp; DELETE keeps the danger
|
||||
outline and the far end of the row.
|
||||
|
||||
COPY PUBLIC KEY under them for a key that has one, because a phone is where somebody is standing at a
|
||||
machine that needs it in authorized_keys.
|
||||
-->
|
||||
<Border Grid.Row="2" IsVisible="{Binding ShowsItemActions}" Margin="16,4"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="12" Padding="14,10">
|
||||
<Grid ColumnDefinitions="*,10,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="label" Text="SELECTED" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||
Text="{Binding SelectedVaultItem.Name}" TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="tag outline">
|
||||
<TextBlock Text="{Binding SelectedVaultItem.Type}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
BorderThickness="1" CornerRadius="14" Padding="14,12">
|
||||
<StackPanel Spacing="10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="mono" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
Text="{Binding SelectedVaultItem.Name}" TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="tag outline">
|
||||
<TextBlock Text="{Binding SelectedVaultItem.Type}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
MOVE beside it, and only where there is somewhere to move to — the rule the host's MOVE follows on
|
||||
this head, for the reason a phone has: there is no room to draw a button that answers with a
|
||||
refusal. It is the ghost of the pair rather than the danger one, because a move is undone by
|
||||
moving it back.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="secondary" Height="44" Width="86" Content="MOVE"
|
||||
<Grid ColumnDefinitions="*,8,*,8,*">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
||||
Command="{Binding EditSelectedItemCommand}" />
|
||||
<!--
|
||||
MOVE only where there is somewhere to move to — the rule the host's MOVE follows on this head, for
|
||||
the reason a phone has: there is no room to draw a button that answers with a refusal.
|
||||
-->
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
||||
IsVisible="{Binding CanMoveSelectedItem}"
|
||||
Command="{Binding MoveSelectedItemCommand}" />
|
||||
<Button Classes="danger" Height="44" Width="104" Content="DELETE"
|
||||
<Button Grid.Column="4" Classes="danger" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteSelectedItemCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Button Classes="secondary" Height="44" Content="COPY PUBLIC KEY"
|
||||
Command="{Binding CopyPublicKeyCommand}"
|
||||
IsVisible="{Binding SelectedKey.Key.PublicKey, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ ◆ the counted confirmation ============ -->
|
||||
@@ -183,9 +198,9 @@
|
||||
IsVisible="{Binding PendingDeletion.HasUsage}"
|
||||
Text="{Binding PendingDeletion.Usage}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||||
<Button Grid.Column="0" Classes="danger" Content="DELETE"
|
||||
<Button Grid.Column="2" Classes="danger" Content="DELETE"
|
||||
Command="{Binding ConfirmDeleteCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
Command="{Binding CancelDeleteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -224,9 +239,9 @@
|
||||
IsVisible="{Binding HasMovingItemUsage}"
|
||||
Text="{Binding MovingItemUsage}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="MOVE"
|
||||
Command="{Binding ConfirmMoveItemCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveItemCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -239,12 +254,15 @@
|
||||
<TextBlock Classes="body" IsVisible="{Binding !HasVaultItems}" Margin="24,20"
|
||||
VerticalAlignment="Top" Text="{Binding EmptySectionMessage}" />
|
||||
|
||||
<ListBox ItemsSource="{Binding VaultItems}" SelectedItem="{Binding SelectedVaultItem}"
|
||||
<!-- The foot padding is the floating button's: the last card scrolls clear of it. -->
|
||||
<ListBox ItemsSource="{Binding VaultItems}" SelectedItem="{Binding SelectedVaultItem}" Padding="0,0,0,88"
|
||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding HasVaultItems}">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<!-- A name for TalkBack rather than the row view model's type name (audit H4). -->
|
||||
<Setter Property="AutomationProperties.Name" Value="{ReflectionBinding Name}" />
|
||||
</Style>
|
||||
<!--
|
||||
The fill goes on the card and not on the ContentPresenter behind it, which is where HOSTS and
|
||||
@@ -255,28 +273,28 @@
|
||||
-->
|
||||
<Style Selector="ListBoxItem:selected Border.card">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||||
<Border Classes="card" Margin="12,4">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="64">
|
||||
<Border Classes="card" Margin="16,4" CornerRadius="16" Background="{StaticResource Panel}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="56">
|
||||
|
||||
<!--
|
||||
The glyph tile. One mark for all three kinds rather than three: a key, a password and a
|
||||
bucket are already named by the badge beside them, and three near-identical monochrome
|
||||
glyphs at 14px would be decoration that reads as a distinction.
|
||||
-->
|
||||
<Border Grid.Column="0" Width="36" Height="36" CornerRadius="10"
|
||||
Background="{StaticResource Active}" VerticalAlignment="Center">
|
||||
<TextBlock Text="⚿" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
FontWeight="SemiBold" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
<Border Grid.Column="0" Width="40" Height="40" CornerRadius="12"
|
||||
Background="{StaticResource AccentSoft}" VerticalAlignment="Center">
|
||||
<TextBlock Classes="icon" Text="{Binding IconGlyph}" FontSize="20"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="12,0" Spacing="3" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||
<TextBlock Classes="mono" FontSize="14.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="tag outline">
|
||||
<TextBlock Text="{Binding Type}" />
|
||||
@@ -287,18 +305,310 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- What is known *about* an item, never the secret. -->
|
||||
<TextBlock Classes="detail" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail code" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="icon" Text="" Foreground="{StaticResource TextGhost}"
|
||||
FontSize="22" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- ============ add ============ -->
|
||||
<!--
|
||||
◆ The floating +, which this screen never had: its empty state asked for an SSH key or a password over
|
||||
a screen with no way to add either (audit M9). It raises the add sheet below, because there are five
|
||||
kinds of thing to add here and one button.
|
||||
-->
|
||||
<Button Classes="fab" Margin="0,0,18,18" HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
AutomationProperties.Name="Add to the keychain" Command="{Binding OpenKeychainAddSheetCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="26" />
|
||||
</Button>
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ the add sheet ============ -->
|
||||
<!--
|
||||
The desktop's "+ New key" menu, as the hosts screen's add sheet: a scrim, a bottom panel, dismissible
|
||||
because "which kind" has no wrong answer. Each row opens its editor and lowers the sheet as it does —
|
||||
see KeychainScreen.OnAddChosen. Generating comes first, as it does on the desktop: on a phone it is
|
||||
the way to a key that never existed as a file anywhere.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsKeychainAddSheetOpen}">
|
||||
<Button Classes="scrim" Command="{Binding CloseKeychainAddSheetCommand}" />
|
||||
|
||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
CornerRadius="22,22,0,0" Padding="12,10,12,16">
|
||||
<StackPanel>
|
||||
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,12" />
|
||||
<TextBlock Classes="label" Text="ADD TO THE KEYCHAIN" Margin="12,0,12,6" />
|
||||
|
||||
<Button Classes="row" Command="{Binding NewGeneratedKeyCommand}" Click="OnAddChosen"
|
||||
AutomationProperties.Name="Generate a key">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Generate a key" />
|
||||
<TextBlock Classes="detail" Text="A new key pair made here, so the private half never becomes a file." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Classes="row" Command="{Binding NewKeyCommand}" Click="OnAddChosen"
|
||||
AutomationProperties.Name="Add an SSH key">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add an SSH key" />
|
||||
<TextBlock Classes="detail" Text="Paste in a key you already have." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Classes="row" Command="{Binding NewCredentialCommand}" Click="OnAddChosen"
|
||||
AutomationProperties.Name="Add a password">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add a password" />
|
||||
<TextBlock Classes="detail" Text="One account, described once, for as many hosts as use it." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Classes="row" Command="{Binding NewObjectStoreCommand}" Click="OnAddChosen"
|
||||
AutomationProperties.Name="Add a bucket">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add a bucket" />
|
||||
<TextBlock Classes="detail" Text="An S3-compatible bucket, to browse under Settings, Files." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Classes="row" Command="{Binding NewTagCommand}" Click="OnAddChosen"
|
||||
AutomationProperties.Name="Add a tag">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add a tag" />
|
||||
<TextBlock Classes="detail" Text="A name to put on hosts, before there is anything to put it on." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ ◆ the editor page ============ -->
|
||||
<!--
|
||||
The desktop's five keychain editors, as one page over the whole screen — the host editor's shape on this
|
||||
head, and PhoneShell stands its chrome down for it the same way (see PhoneShell.RefreshChrome). Only one
|
||||
of the five is ever open; each draws its own section and its own CANCEL and SAVE, and the ← in the
|
||||
header cancels whichever it is, as the system back gesture does.
|
||||
|
||||
Every label is a label above its box rather than a placeholder in it, which the audit asked of the
|
||||
host editor too: on a phone the box is full of what was typed long before anybody is done reading it.
|
||||
-->
|
||||
<Grid IsVisible="{Binding IsEditingAVaultItem}" RowDefinitions="Auto,*" Background="{StaticResource Canvas}">
|
||||
<Border Grid.Row="0" Height="56" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*" Margin="8,0">
|
||||
<Button Grid.Column="0" Classes="icon" AutomationProperties.Name="Cancel" Click="OnCancelEditor">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Generate a key"
|
||||
IsVisible="{Binding IsGeneratingKey}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="SSH key"
|
||||
IsVisible="{Binding IsEditingKey}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Password"
|
||||
IsVisible="{Binding IsEditingCredential}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Bucket"
|
||||
IsVisible="{Binding IsEditingObjectStore}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Tag"
|
||||
IsVisible="{Binding IsEditingTag}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel Margin="16,14,16,24" Spacing="8">
|
||||
|
||||
<!--
|
||||
Which vault a new item is filed into, hidden at one vault, which is where most people stay. The
|
||||
desktop's own "NEW ITEMS FILE TO", and the sentence under it for the reason that one has it.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasVaultChoice}" Margin="0,0,0,8">
|
||||
<TextBlock Classes="label" Text="NEW ITEMS FILE TO" />
|
||||
<ComboBox ItemsSource="{Binding TargetVaults}" SelectedItem="{Binding SelectedTargetVault}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Display}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="detail" Text="An item filed into a shared vault is readable by everyone holding that vault's key." />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ generating ============ -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsGeneratingKey}">
|
||||
<TextBlock Classes="label" Text="KIND" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="chiptoggle" Classes.worn="{Binding GeneratesEd25519}" MinHeight="44"
|
||||
Command="{Binding ChooseKeyAlgorithmCommand}"
|
||||
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Ed25519}">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="Ed25519" />
|
||||
</Button>
|
||||
<Button Classes="chiptoggle" Classes.worn="{Binding GeneratesRsa}" MinHeight="44"
|
||||
Command="{Binding ChooseKeyAlgorithmCommand}"
|
||||
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Rsa4096}">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="RSA 4096" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="detail" Text="Ed25519 is what every current OpenSSH prefers. RSA is for servers too old to accept it, and takes a few seconds to make." />
|
||||
|
||||
<TextBlock Classes="label" Text="NAME" Margin="0,8,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding GenerateComment}" />
|
||||
<TextBlock Classes="detail" Text="What the key is called here, and the comment written into it, so the line on a host says where it came from." />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,8,0,0"
|
||||
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read. It lands in the key editor unsaved, to be named and kept." />
|
||||
|
||||
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL"
|
||||
Command="{Binding CancelGenerateKeyCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Content="GENERATE"
|
||||
Command="{Binding GenerateKeyCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ a key ============ -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsEditingKey}">
|
||||
<TextBlock Classes="label" Text="NAME" />
|
||||
<TextBox Classes="field" Text="{Binding KeyEditorLabel}" />
|
||||
|
||||
<!--
|
||||
Not a password box, for the desktop's reason: the armour has to be visible to be pasted and
|
||||
checked, and the mistake this prevents is pasting the .pub file, which is refused by name.
|
||||
-->
|
||||
<TextBlock Classes="label" Text="PRIVATE KEY" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding KeyEditorPrivateKey}" Height="140"
|
||||
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
AcceptsReturn="True" TextWrapping="NoWrap" VerticalContentAlignment="Top"
|
||||
Padding="12,10" FontSize="11" />
|
||||
|
||||
<TextBlock Classes="label" Text="PASSPHRASE, IF THE KEY HAS ONE" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field secret" Text="{Binding KeyEditorPassphrase}" />
|
||||
|
||||
<TextBlock Classes="label" Text="PUBLIC HALF (OPTIONAL)" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding KeyEditorPublicKey}" FontSize="11" />
|
||||
|
||||
<TextBlock Classes="label" Text="NOTES" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding KeyEditorNotes}" Height="72" AcceptsReturn="True"
|
||||
TextWrapping="Wrap" VerticalContentAlignment="Top" Padding="12,10" />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,6,0,0"
|
||||
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read." />
|
||||
|
||||
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveKeyCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ a password ============ -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsEditingCredential}">
|
||||
<TextBlock Classes="label" Text="NAME" />
|
||||
<TextBox Classes="field" Text="{Binding CredentialEditorLabel}" />
|
||||
|
||||
<TextBlock Classes="label" Text="USERNAME" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding CredentialEditorUsername}"
|
||||
PlaceholderText="blank: each host's own" />
|
||||
|
||||
<TextBlock Classes="label" Text="PASSWORD" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field secret" Text="{Binding CredentialEditorPassword}" />
|
||||
|
||||
<TextBlock Classes="label" Text="NOTES" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding CredentialEditorNotes}" Height="72" AcceptsReturn="True"
|
||||
TextWrapping="Wrap" VerticalContentAlignment="Top" Padding="12,10" />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,6,0,0"
|
||||
Text="Encrypted here and never sent to the server in a form it can read. Bind a host to it in the host's own editor, under how it signs in." />
|
||||
|
||||
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL"
|
||||
Command="{Binding CancelCredentialEditCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveCredentialCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ a bucket ============ -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsEditingObjectStore}">
|
||||
<TextBlock Classes="label" Text="NAME" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorLabel}" />
|
||||
|
||||
<TextBlock Classes="label" Text="BUCKET" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorBucket}" />
|
||||
|
||||
<TextBlock Classes="label" Text="ACCESS KEY ID" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorAccessKeyId}" />
|
||||
|
||||
<TextBlock Classes="label" Text="SECRET ACCESS KEY" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field secret" Text="{Binding BucketEditorSecretAccessKey}" />
|
||||
|
||||
<TextBlock Classes="label" Text="REGION" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorRegion}" PlaceholderText="e.g. eu-west-1" />
|
||||
|
||||
<TextBlock Classes="label" Text="ENDPOINT" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorEndpoint}" PlaceholderText="blank: Amazon S3" />
|
||||
|
||||
<CheckBox IsChecked="{Binding BucketEditorUsePathStyle}" MinHeight="44">
|
||||
<TextBlock FontSize="13.5" Foreground="{StaticResource Text}" TextWrapping="Wrap"
|
||||
Text="Address the bucket as a path" />
|
||||
</CheckBox>
|
||||
<TextBlock Classes="detail"
|
||||
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
|
||||
|
||||
<TextBlock Classes="label" Text="NOTES" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorNotes}" Height="72" AcceptsReturn="True"
|
||||
TextWrapping="Wrap" VerticalContentAlignment="Top" Padding="12,10" />
|
||||
|
||||
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL"
|
||||
Command="{Binding CancelObjectStoreEditCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveObjectStoreCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ a tag ============ -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsEditingTag}">
|
||||
<TextBlock Classes="label" Text="NAME" />
|
||||
<TextBox Classes="field" Text="{Binding TagEditorLabel}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SaveTagCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBlock Classes="detail"
|
||||
Text="Renaming a tag changes it everywhere at once. No host is rewritten — each one names this tag rather than repeating its name." />
|
||||
|
||||
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL" Command="{Binding CancelTagEditCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveTagCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="detail" Margin="0,8,0,0" Text="{Binding Status}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
</Panel>
|
||||
|
||||
</UserControl>
|
||||
@@ -1,10 +1,70 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>Design 04 — everything in the vault that is not a host.</summary>
|
||||
internal sealed partial class KeychainScreen : UserControl
|
||||
{
|
||||
public KeychainScreen() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels whichever of the keychain's five editors is open.
|
||||
/// </summary>
|
||||
/// <returns>Whether one was, and so whether there was anything to cancel.</returns>
|
||||
/// <remarks>
|
||||
/// One place for the question the editor page's ← and the system back gesture both ask. Only one editor is
|
||||
/// ever open — each one's command refuses while another is — so the order only decides which test runs
|
||||
/// first. Cancelling rather than hiding, as the host editor does, because cancelling is what empties the
|
||||
/// boxes, and two of them hold a private key and a password.
|
||||
/// </remarks>
|
||||
internal static bool TryCancelAnEditor(VaultViewModel vault)
|
||||
{
|
||||
if (vault.IsGeneratingKey)
|
||||
{
|
||||
vault.CancelGenerateKeyCommand.Execute(null);
|
||||
}
|
||||
else if (vault.IsEditingKey)
|
||||
{
|
||||
vault.CancelKeyEditCommand.Execute(null);
|
||||
}
|
||||
else if (vault.IsEditingCredential)
|
||||
{
|
||||
vault.CancelCredentialEditCommand.Execute(null);
|
||||
}
|
||||
else if (vault.IsEditingObjectStore)
|
||||
{
|
||||
vault.CancelObjectStoreEditCommand.Execute(null);
|
||||
}
|
||||
else if (vault.IsEditingTag)
|
||||
{
|
||||
vault.CancelTagEditCommand.Execute(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnCancelEditor(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is VaultViewModel vault)
|
||||
{
|
||||
TryCancelAnEditor(vault);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A kind was chosen on the add sheet: the sheet goes down as its editor opens.</summary>
|
||||
private void OnAddChosen(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is VaultViewModel vault)
|
||||
{
|
||||
vault.IsKeychainAddSheetOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<StackPanel Spacing="1" Margin="0,5">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding Started}"
|
||||
<TextBlock Classes="detail code" FontSize="10" Text="{Binding Started}"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
@@ -104,7 +104,7 @@
|
||||
Text="{Binding Kind}" Foreground="{StaticResource Live}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
|
||||
<TextBlock Classes="detail code" FontSize="10" Text="{Binding HostLabel}"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
@@ -148,7 +148,7 @@
|
||||
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
|
||||
<StackPanel Spacing="1" Margin="0,5">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding At}"
|
||||
<TextBlock Classes="detail code" FontSize="10" Text="{Binding At}"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="detail" FontSize="10" FontWeight="SemiBold"
|
||||
Text="{Binding Operation}" Foreground="{StaticResource Info}"
|
||||
|
||||
@@ -6,190 +6,267 @@
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<!--
|
||||
Design v2 — SETTINGS: the hub for everything the bottom bar has no room for.
|
||||
SETTINGS: the hub behind the bottom bar's third entry.
|
||||
|
||||
Three slots and ten destinations is the arithmetic the design solves by putting seven of them one tap
|
||||
deeper. This screen is that tap. It takes the shell as its data context rather than the vault, because
|
||||
every row on it is a navigation command and nothing here reads an item.
|
||||
It takes the shell as its data context rather than the vault, because nearly every row on it is a
|
||||
navigation command and nothing here reads an item.
|
||||
|
||||
Teams is the tenth and the design never drew it — see the row itself. That one is the design's count
|
||||
plus one rather than a rearrangement of it. The keychain below is a rearrangement, and the only one:
|
||||
the bottom bar lost a slot to gain this row.
|
||||
── the 2026-10-04 redesign ────────────────────────────────────────────────────────────────────────────
|
||||
The hub used to hold the product: the keychain, snippets, SFTP and S3 were rows here beside Preferences
|
||||
and LOCK, and the audit's M8 said so. The keychain and snippets now live under VAULTS, in the open vault's
|
||||
own switch, and what is left is arranged the way a phone's settings are — who you are, then grouped
|
||||
sections of rows on cards — rather than one flat list of places.
|
||||
|
||||
The rows are the design's list rows rather than cards: a card is one thing you act on, and a destination
|
||||
is not a thing — it is a place. Each carries a sentence saying what is behind it, because a hub whose
|
||||
entries are one word each is a menu you have to open to read.
|
||||
The account card says who is signed in and where, which nothing on this head said before: the server's
|
||||
address is the fact that decides whose keychain this is, and it was only ever typed once, on the first
|
||||
screen. It is not a button; there is no account page on the phone to open (signing out is on the lock
|
||||
screen — see PreferencesScreen's NOT HERE).
|
||||
|
||||
── from MORE to SETTINGS ──────────────────────────────────────────────────────────────────────────────
|
||||
The hub was called More because that was the honest name for a handful of things that did not fit. With
|
||||
the keychain on it as well it is the place everything that is not connecting to a machine lives, which
|
||||
has a name of its own and a glyph everyone already knows.
|
||||
Rows are cards of rows rather than cards each, because a destination is a place and not a thing, and a
|
||||
group of them reads as one section only when they share a surface. Each keeps a sentence saying what is
|
||||
behind it, because a hub whose entries are one word each is a menu you have to open to read.
|
||||
|
||||
The keychain is first, and above the rule, because it is the only row here that holds the user's own
|
||||
material rather than a screen about the application. Everything below it is somewhere to look at what
|
||||
has happened or how this phone behaves.
|
||||
Every icon is the embedded Material font's, as the bar's are — see NavButton, which says why the
|
||||
Unicode glyphs went (audit M7).
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
<!-- A section's rows. The card's own padding is the rows' business, so a row's press fill reaches its
|
||||
edges; the hairlines between rows are drawn by the rows' containers. -->
|
||||
<Style Selector="Border.settingsgroup">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
<Setter Property="ClipToBounds" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="Button.settingsrow">
|
||||
<Setter Property="MinHeight" Value="60" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Border.rowrule">
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="Margin" Value="62,0,0,0" />
|
||||
<Setter Property="Background" Value="{StaticResource BorderSubtle}" />
|
||||
</Style>
|
||||
<!-- The tile an icon sits on: a square of the accent's wash, so a row's mark is a colour as well as a
|
||||
shape. Amber for the one row that ends something rather than going somewhere. -->
|
||||
<Style Selector="Border.rowtile">
|
||||
<Setter Property="Width" Value="36" />
|
||||
<Setter Property="Height" Value="36" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentSoft}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Border.rowtile > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="FontSize" Value="19" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.rowtitle">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontSize" Value="14.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.chevron">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Height="56" Padding="16,0">
|
||||
<TextBlock Classes="heading" Text="Settings" />
|
||||
<Border Grid.Row="0" Height="60" Padding="20,0" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<TextBlock Classes="heading" FontSize="22" FontWeight="Bold" Text="Settings" />
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel Margin="10,0,10,20" Spacing="2">
|
||||
<StackPanel Margin="16,14,16,24" Spacing="8">
|
||||
|
||||
<!-- ============ who, and where ============ -->
|
||||
<Border Classes="settingsgroup" Padding="14">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<Border Grid.Column="0" Width="48" Height="48" CornerRadius="24"
|
||||
Background="{StaticResource AvatarGradient}">
|
||||
<TextBlock Text="{Binding AvatarInitials}" FontSize="17" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentInk}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock FontSize="16" FontWeight="Bold" Foreground="{StaticResource Text}"
|
||||
Text="{Binding AccountName}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock FontSize="12.5" Foreground="{StaticResource TextDim}" Text="{Binding Email}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding ServerUrl}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ security ============ -->
|
||||
<TextBlock Classes="label" Text="SECURITY" Margin="6,14,0,2" />
|
||||
<Border Classes="settingsgroup">
|
||||
<StackPanel>
|
||||
<!--
|
||||
The fingerprint is a row to Preferences rather than a switch here. Turning it on registers a key
|
||||
in the phone's secure hardware and turning it off withdraws one — two operations with a prompt
|
||||
and a paragraph each, which live on that screen — so this row says which state the phone is in
|
||||
and goes there. ON and OFF are the two flags the shell already computes; a phone with no way to
|
||||
hold a key says neither and the screen behind explains.
|
||||
-->
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="Unlock with a fingerprint"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto,4,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="Unlock with a fingerprint" />
|
||||
<TextBlock Classes="detail" Text="The key stays in this phone's secure hardware." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="label" FontSize="10.5" Text="ON"
|
||||
Foreground="{StaticResource Live}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding CanForgetDevice}" />
|
||||
<TextBlock Grid.Column="4" Classes="label" FontSize="10.5" Text="OFF"
|
||||
VerticalAlignment="Center" IsVisible="{Binding CanRegisterDevice}" />
|
||||
<TextBlock Grid.Column="6" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Border Classes="rowrule" />
|
||||
<!--
|
||||
Locking, here as well as in the vault's header. The header is on the VAULTS tab and stands down
|
||||
on this one, so without this row every screen under SETTINGS would be two taps from locking the
|
||||
keychain — the wrong direction for the one action whose whole value is being quick.
|
||||
-->
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="Lock the keychain now"
|
||||
Command="{Binding LockCommand}">
|
||||
<Grid ColumnDefinitions="Auto,12,*">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="Lock now" />
|
||||
<TextBlock Classes="detail" Text="Shells you have open keep running." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ vaults and history ============ -->
|
||||
<TextBlock Classes="label" Text="VAULTS AND HISTORY" Margin="6,14,0,2" />
|
||||
<Border Classes="settingsgroup">
|
||||
<StackPanel>
|
||||
<!--
|
||||
Vaults, which the v2 design had no row for. It is on the phone because a vault arrives without
|
||||
being asked for, and somebody it arrives for is at least as likely to be holding a phone. The
|
||||
vault name in the VAULTS tab's header leads here too.
|
||||
-->
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="Vaults"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="Vaults and sharing" />
|
||||
<TextBlock Classes="detail" Text="Your vaults, who is in each one, and who holds its key." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Border Classes="rowrule" />
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="Logs"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="Activity log" />
|
||||
<TextBlock Classes="detail" Text="What has been connected to, and what has changed." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ files ============ -->
|
||||
<!--
|
||||
"Keychain", although the design's tab says Vault and the enum member is Vault. The split is a
|
||||
standing rule rather than an inconsistency: every sentence a user reads calls the encrypted store a
|
||||
keychain, and everything under the surface calls it a vault, because renaming the wire route, the
|
||||
tables and the CLR types would put an AAD resource type inside the blast radius of a
|
||||
find-and-replace. See docs/design-import-gaps.md. A row reading Vault would be the one place the
|
||||
product surface leaked the implementation's word.
|
||||
SFTP and S3 stay reachable from here, and the terminal now reaches a host's files directly — the
|
||||
FILES entry in a session's menu opens them on that session's host. A bucket has no session, so this
|
||||
is still the way to one. Both go through ShowFiles rather than ShowScreen: they are one screen over
|
||||
one view model, and which kind of remote it offers is the thing being chosen.
|
||||
-->
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Keychain}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="⚿" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Keychain" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Keys, passwords, buckets and tags — everything in the vault that is not a host." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<TextBlock Classes="label" Text="FILES" Margin="6,14,0,2" />
|
||||
<Border Classes="settingsgroup">
|
||||
<StackPanel>
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="SFTP"
|
||||
Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="SFTP" />
|
||||
<TextBlock Classes="detail" Text="Browse a host's files." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Border Classes="rowrule" />
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="S3"
|
||||
Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="S3" />
|
||||
<TextBlock Classes="detail" Text="Objects in an S3-compatible bucket from the keychain." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Height="1" Background="{StaticResource Border}" Margin="16,8" />
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="❯" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Snippets" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="Saved commands, typed into an open shell." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
These two go through ShowFiles rather than ShowScreen: they are one screen over one view model,
|
||||
and which kind of remote it offers is the thing being chosen. See ShowFilesCommand for why that
|
||||
is set by the button rather than on arrival.
|
||||
-->
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="⇅" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="SFTP" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Browse a host's files." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="◳" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="S3" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Objects in an S3-compatible bucket from the keychain." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="≡" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Logs" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="What has been connected to, and what has changed." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Vaults, which the v2 design has no row for — it is a shipped screen the design had no slot for
|
||||
rather than a drawn one with nothing behind it. It is on the phone because a vault arrives
|
||||
without being asked for, and somebody it arrives for is at least as likely to be holding a phone.
|
||||
|
||||
◎ rather than a glyph of its own. The desktop rail already draws this destination with it, and
|
||||
two heads giving one destination two marks is how a user learns the wrong one.
|
||||
-->
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="◎" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Vaults" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Your vaults, who is in each one, and who holds its key." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="⚙" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Preferences" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Unlocking with a fingerprint, and what this phone cannot do." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Locking, here as well as in the header.
|
||||
|
||||
The header carries it on HOSTS and stands down behind SETTINGS, so without this row every screen
|
||||
under here would be two taps from locking the keychain. That is the wrong direction for the one
|
||||
action whose whole value is being quick, and it is why this is a row on the hub rather than
|
||||
something you go back for.
|
||||
-->
|
||||
<Button Classes="row" Margin="0,10,0,0" Command="{Binding LockCommand}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Text="⚿" Foreground="{StaticResource TextDim}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Lock the keychain" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Shells you have open keep running." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
<!-- ============ this app ============ -->
|
||||
<!-- Preferences with tune rather than a gear: the gear is the tab this row is under (audit M7). -->
|
||||
<TextBlock Classes="label" Text="THIS APP" Margin="6,14,0,2" />
|
||||
<Border Classes="settingsgroup">
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="Preferences"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto,4,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="Preferences and updates" />
|
||||
<TextBlock Classes="detail" Text="Fingerprint unlock, updates, and what this phone cannot do." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="mono" FontSize="11" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding Updates.CurrentVersion}" VerticalAlignment="Center"
|
||||
MaxWidth="110" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="6" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ Port forwarding, stated rather than drawn.
|
||||
|
||||
The v2 design gives this hub a sixth entry and a full screen behind it: local, remote and dynamic
|
||||
rules with toggles and byte counters. Forwarding is built and runs here as on the desktop — a host's
|
||||
The v2 design gives this hub an entry and a full screen behind it: local, remote and dynamic rules
|
||||
with toggles and byte counters. Forwarding is built and runs here as on the desktop — a host's
|
||||
forwards start with every terminal opened to it — but it is not a destination, so it has no row. A
|
||||
forward belongs to a host and runs with a session, and on this head the session's forwards are a
|
||||
sheet off the terminal's bar: the ⇄ button, drawn for a tab that has some, lists them with a switch
|
||||
@@ -202,13 +279,12 @@
|
||||
It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail:
|
||||
a place somebody looks for a feature should say where it went. See docs/design-import-gaps.md.
|
||||
-->
|
||||
<Border Classes="card" Margin="6,16,6,0">
|
||||
<Border Classes="card" Margin="0,14,0,0">
|
||||
<StackPanel Spacing="7">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9">
|
||||
<TextBlock Text="⇄" Foreground="{StaticResource TextFaint}" FontSize="14"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextDim}" Text="Port forwarding" />
|
||||
<TextBlock Classes="icon" Text="" Foreground="{StaticResource TextFaint}" FontSize="18" />
|
||||
<TextBlock FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource TextDim}"
|
||||
Text="Port forwarding" VerticalAlignment="Center" />
|
||||
<Border Classes="tag outline" VerticalAlignment="Center">
|
||||
<TextBlock Text="IN THE TERMINAL" />
|
||||
</Border>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Globalization;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Automation;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Layout;
|
||||
@@ -17,16 +20,31 @@ namespace DodoSSH.Client.Android.Views;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Written in C# rather than as a XAML control because it is a button with a glyph, a label and a rule about
|
||||
/// the accent bar, and a templated control for that would be more machinery than the thing it draws.
|
||||
/// Written in C# rather than as a XAML control because it is a button with an icon, a label and a rule about
|
||||
/// the indicator, and a templated control for that would be more machinery than the thing it draws.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The accent bar is on top, not underneath.</b> The v2 design distinguishes the current destination with
|
||||
/// colour and weight alone and draws no bar at all; the bar is kept, and kept at the top, for a reason the
|
||||
/// design does not have to think about. The bottom of a phone screen is under the system's own gesture bar,
|
||||
/// so an indicator drawn there is one the user cannot see — and colour alone at 9pt on a dark background is
|
||||
/// the single signal a user with a colour deficiency would be left with. Weight, colour and the bar are
|
||||
/// three, which is the right number for the control that says where you are.
|
||||
/// ◆ <b>The indicator is a pill behind the icon, where it was a bar along the top edge.</b> The bar was put at
|
||||
/// the top because the bottom of the screen is under the system's gesture bar, and an indicator drawn there is
|
||||
/// one nobody sees; the pill answers the same worry from the other side, by sitting in the middle of the
|
||||
/// control rather than at either edge. It is the shape Android's own navigation bar has drawn since Material 3
|
||||
/// and the one the 2026-10-04 redesign asks for. The rule the bar existed to keep is kept: the current entry is
|
||||
/// still told apart three ways — the filled pill, the accent colour and the heavier label — because colour
|
||||
/// alone at this size is the one signal a user with a colour deficiency would be left with.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>An icon from the embedded Material font rather than a Unicode glyph.</b> The glyphs (▤ ⌗ ⚙) came from
|
||||
/// whichever symbol font the phone had, at whatever weight it drew them, and ⚙ meant both this tab and the
|
||||
/// Preferences row under it (audit M7). The icon font is the one the desktop's rail uses.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A count, when there is one.</b> Connections carries the number of open sessions in a green badge, which
|
||||
/// was otherwise only said by the strip of shells above the bar. Zero draws nothing: a "0" is a number about
|
||||
/// nothing, and the badge being absent already says it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It has a name.</b> Its content is a panel, and Avalonia's automation peer reported that panel's type name
|
||||
/// to TalkBack — "Avalonia.Controls.Panel", three times across the bar (audit H4). The label is the name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every colour here is resolved from <c>Theme/Palette.axaml</c> rather than written out. A nav bar that
|
||||
@@ -39,25 +57,28 @@ internal sealed class NavButton : Button
|
||||
public static readonly StyledProperty<string> LabelProperty =
|
||||
AvaloniaProperty.Register<NavButton, string>(nameof(Label), string.Empty);
|
||||
|
||||
/// <summary>The glyph drawn above the label.</summary>
|
||||
/// <remarks>
|
||||
/// A character rather than an icon asset. The four the design uses — ▤ ⌗ ⚿ ≣ — are all in the fonts
|
||||
/// Android already has, so shipping four vectors to draw them would buy nothing but four more files to
|
||||
/// keep in step with the palette.
|
||||
/// </remarks>
|
||||
/// <summary>The icon drawn above the label: one codepoint of the embedded Material Icons font.</summary>
|
||||
public static readonly StyledProperty<string> GlyphProperty =
|
||||
AvaloniaProperty.Register<NavButton, string>(nameof(Glyph), string.Empty);
|
||||
|
||||
public static readonly StyledProperty<bool> IsCurrentProperty =
|
||||
AvaloniaProperty.Register<NavButton, bool>(nameof(IsCurrent));
|
||||
|
||||
private readonly Border indicator;
|
||||
/// <summary>A count drawn over the icon, or zero for none.</summary>
|
||||
public static readonly StyledProperty<int> CountProperty =
|
||||
AvaloniaProperty.Register<NavButton, int>(nameof(Count));
|
||||
|
||||
private readonly Border pill;
|
||||
private readonly TextBlock glyph;
|
||||
private readonly TextBlock text;
|
||||
private readonly Border badge;
|
||||
private readonly TextBlock badgeText;
|
||||
|
||||
// Resolved from the palette once the control is in the tree; see Repaint.
|
||||
private IBrush? currentBrush;
|
||||
private IBrush? currentIconBrush;
|
||||
private IBrush? idleBrush;
|
||||
private IBrush? pillBrush;
|
||||
|
||||
public NavButton()
|
||||
{
|
||||
@@ -70,35 +91,71 @@ internal sealed class NavButton : Button
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch;
|
||||
VerticalContentAlignment = VerticalAlignment.Stretch;
|
||||
|
||||
indicator = new Border
|
||||
{
|
||||
Height = 2,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Background = Brushes.Transparent,
|
||||
};
|
||||
|
||||
glyph = new TextBlock
|
||||
{
|
||||
FontSize = 15,
|
||||
FontSize = 21,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
AutomationProperties.SetAccessibilityView(glyph, AccessibilityView.Raw);
|
||||
|
||||
(badge, badgeText) = MakeBadge();
|
||||
|
||||
pill = new Border
|
||||
{
|
||||
Width = 64,
|
||||
Height = 32,
|
||||
CornerRadius = new CornerRadius(16),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Background = Brushes.Transparent,
|
||||
Child = glyph,
|
||||
};
|
||||
|
||||
text = new TextBlock
|
||||
{
|
||||
FontSize = 9,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeight.Medium,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
|
||||
var stack = new StackPanel
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 3,
|
||||
Spacing = 4,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Children = { new Panel { Children = { pill, badge } }, text },
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The count's badge, over the pill's top-right corner.</summary>
|
||||
/// <remarks>
|
||||
/// Over the corner rather than beside the label, so it reads as belonging to the icon and costs the
|
||||
/// label no width.
|
||||
/// </remarks>
|
||||
private static (Border Badge, TextBlock Text) MakeBadge()
|
||||
{
|
||||
var count = new TextBlock
|
||||
{
|
||||
FontSize = 10.5,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Children = { glyph, text },
|
||||
};
|
||||
|
||||
Content = new Panel { Children = { indicator, stack } };
|
||||
var holder = new Border
|
||||
{
|
||||
MinWidth = 18,
|
||||
Height = 18,
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Padding = new Thickness(5, 0),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(0, -4, 8, 0),
|
||||
IsVisible = false,
|
||||
Child = count,
|
||||
};
|
||||
|
||||
return (holder, count);
|
||||
}
|
||||
|
||||
public string Label
|
||||
@@ -121,6 +178,13 @@ internal sealed class NavButton : Button
|
||||
set => SetValue(IsCurrentProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="CountProperty" />
|
||||
public int Count
|
||||
{
|
||||
get => GetValue(CountProperty);
|
||||
set => SetValue(CountProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
@@ -129,6 +193,7 @@ internal sealed class NavButton : Button
|
||||
if (change.Property == LabelProperty)
|
||||
{
|
||||
text.Text = Label;
|
||||
AutomationProperties.SetName(this, Label);
|
||||
}
|
||||
else if (change.Property == GlyphProperty)
|
||||
{
|
||||
@@ -138,9 +203,10 @@ internal sealed class NavButton : Button
|
||||
{
|
||||
Repaint();
|
||||
}
|
||||
else if (change.Property == FontFamilyProperty)
|
||||
else if (change.Property == CountProperty)
|
||||
{
|
||||
text.FontFamily = FontFamily;
|
||||
badge.IsVisible = Count > 0;
|
||||
badgeText.Text = Count.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,13 +217,17 @@ internal sealed class NavButton : Button
|
||||
|
||||
// Resolved here rather than in the constructor: application resources are not reachable while the
|
||||
// control is still being constructed, and a hard-coded stack would drift from Palette.axaml.
|
||||
if (Application.Current?.FindResource("MonoFont") is FontFamily mono)
|
||||
if (Application.Current?.FindResource("IconFont") is FontFamily icons)
|
||||
{
|
||||
text.FontFamily = mono;
|
||||
glyph.FontFamily = icons;
|
||||
}
|
||||
|
||||
currentBrush = Application.Current?.FindResource("AccentText") as IBrush;
|
||||
currentBrush = Application.Current?.FindResource("Text") as IBrush;
|
||||
currentIconBrush = Application.Current?.FindResource("AccentText") as IBrush;
|
||||
idleBrush = Application.Current?.FindResource("TextFaint") as IBrush;
|
||||
pillBrush = Application.Current?.FindResource("AccentSoft") as IBrush;
|
||||
badge.Background = Application.Current?.FindResource("Live") as IBrush;
|
||||
badgeText.Foreground = Application.Current?.FindResource("Canvas") as IBrush;
|
||||
|
||||
// IsCurrent is normally bound and so has usually already been set by the time the template applies,
|
||||
// which is why this runs again here rather than only on the property change.
|
||||
@@ -166,16 +236,21 @@ internal sealed class NavButton : Button
|
||||
|
||||
private void Repaint()
|
||||
{
|
||||
// Both the label and the bar, not just the bar — and the weight as well. See the remark on the type.
|
||||
// The pill, the colour and the weight together. See the remark on the type.
|
||||
var paint = IsCurrent ? currentBrush : idleBrush;
|
||||
var iconPaint = IsCurrent ? currentIconBrush : idleBrush;
|
||||
|
||||
if (paint is not null)
|
||||
{
|
||||
glyph.Foreground = paint;
|
||||
text.Foreground = paint;
|
||||
}
|
||||
|
||||
text.FontWeight = IsCurrent ? FontWeight.SemiBold : FontWeight.Medium;
|
||||
indicator.Background = IsCurrent && currentBrush is not null ? currentBrush : Brushes.Transparent;
|
||||
if (iconPaint is not null)
|
||||
{
|
||||
glyph.Foreground = iconPaint;
|
||||
}
|
||||
|
||||
text.FontWeight = IsCurrent ? FontWeight.Bold : FontWeight.Medium;
|
||||
pill.Background = IsCurrent && pillBrush is not null ? pillBrush : Brushes.Transparent;
|
||||
}
|
||||
}
|
||||
@@ -73,17 +73,28 @@
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ Material icons rather than Unicode glyphs, as the desktop's own rail draws them. The glyphs came from
|
||||
whatever symbol font the phone happened to have, at whatever weight and optical size it drew them — the
|
||||
heavy ❯ beside the hairline ◳ the 2026-10-02 audit pointed at (M7) — and two of them meant two things:
|
||||
⚙ was both the Settings tab and Preferences, ⚿ both the keychain and locking it. The icon font is the
|
||||
one the desktop embeds, so one destination now has one mark on both heads.
|
||||
-->
|
||||
<Style Selector="Button.railentry TextBlock.railglyph">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||
<Setter Property="FontSize" Value="19" />
|
||||
<!-- A ligature codepoint is noise to a screen reader; the entry's name is on the button. -->
|
||||
<Setter Property="AutomationProperties.AccessibilityView" Value="Raw" />
|
||||
<Setter Property="Width" Value="24" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- A destination's name is a word rather than an identifier, so it takes the sans face — see the remark
|
||||
on TextBlock.body in Theme/Phone.axaml. -->
|
||||
<Style Selector="Button.railentry TextBlock.raillabel">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="FontSize" Value="13.5" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</Style>
|
||||
@@ -120,11 +131,11 @@
|
||||
<DockPanel LastChildFill="False">
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Margin="8,0,8,10">
|
||||
<Button Classes="railentry" Classes.active="{Binding IsPreferencesShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Preferences" Classes.active="{Binding IsPreferencesShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⚙" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Preferences" />
|
||||
</Grid>
|
||||
</Button>
|
||||
@@ -134,11 +145,11 @@
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="8,10,8,0" Spacing="2">
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsHostsShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Hosts" Classes.active="{Binding IsHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="▤" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Hosts" />
|
||||
<TextBlock Grid.Column="2" Classes="railcount" Text="{Binding Vault.Hosts.Count}" />
|
||||
</Grid>
|
||||
@@ -155,67 +166,67 @@
|
||||
surface and is that count, drawn as the sessions themselves — a number beside this word would be
|
||||
the same fact said twice, three inches apart.
|
||||
-->
|
||||
<Button Classes="railentry" Classes.active="{Binding IsTerminalSurface}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Connections" Classes.active="{Binding IsTerminalSurface}"
|
||||
Command="{Binding ShowTerminalCommand}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Background="{StaticResource Border}" Margin="4,8" />
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsKeychainShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Keychain" Classes.active="{Binding IsKeychainShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Keychain}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⚿" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Keychain" />
|
||||
<TextBlock Grid.Column="2" Classes="railcount" Text="{Binding Vault.Keys.Count}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsSnippetsShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Snippets" Classes.active="{Binding IsSnippetsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="❯" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Snippets" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsTransfersShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="SFTP" Classes.active="{Binding IsTransfersShowing}"
|
||||
Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⇅" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="SFTP" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsBucketsShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="S3" Classes.active="{Binding IsBucketsShowing}"
|
||||
Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="◳" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="S3" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsLogsShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Logs" Classes.active="{Binding IsLogsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="≡" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Logs" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsVaultsShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Vaults" Classes.active="{Binding IsVaultsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="◎" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Vaults" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
@@ -24,10 +24,11 @@
|
||||
are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
|
||||
|
||||
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
|
||||
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
|
||||
bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
|
||||
computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which
|
||||
is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome.
|
||||
The rows around the screen stand down while a shell is showing: the header and the bottom bar. Both go
|
||||
through flags the control computes, and the bar's is the one that differs — it stays up on Connections
|
||||
with nothing running, which is the terminal surface drawing a page rather than a shell. See
|
||||
PhoneShell.RefreshChrome. (A third row, the strip of open shells, was removed by the 2026-10-04 redesign;
|
||||
the count on Connections took its place — see the remark where it was.)
|
||||
|
||||
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
|
||||
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
|
||||
@@ -78,7 +79,7 @@
|
||||
<views:PhoneRail Grid.Column="0"
|
||||
IsVisible="{Binding $parent[views:PhoneShell].ShowsRail}" />
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*,Auto,Auto">
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!--
|
||||
The header: which vault, and whether it is synced.
|
||||
@@ -106,34 +107,53 @@
|
||||
header in HostsScreen, which the header stands down for, both move with it for the same reason;
|
||||
see the remark on each.
|
||||
-->
|
||||
<StackPanel>
|
||||
<Border Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
|
||||
BorderThickness="0,0,0,1" Padding="10,0,6,0" Height="60">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!--
|
||||
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
|
||||
fact about the vault, and the design gives it the accent as a solid tile — which is also what
|
||||
the launcher icon draws, so the two agree.
|
||||
-->
|
||||
<Border Grid.Column="0" Width="26" Height="26" CornerRadius="8"
|
||||
Background="{StaticResource Accent}" VerticalAlignment="Center">
|
||||
<TextBlock Text=">_" Foreground="{StaticResource AccentInk}"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="Bold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
◆ The vault's name is a button now, and it opens the vaults.
|
||||
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="10,0,8,0" FontSize="16"
|
||||
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
|
||||
The 2026-10-04 redesign draws the name with a chevron, as the switch it is on every phone app
|
||||
that holds more than one of something. What it leads to is VAULTS, which is where the vaults
|
||||
this phone shows are chosen — so the chevron is a promise the screen behind it keeps, rather
|
||||
than a dropdown this head has not got.
|
||||
|
||||
The mark stays filled, and takes the gradient Button.primary has: it is the one thing on this
|
||||
header that is not a fact about the vault, and the launcher icon draws it the same way.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="row" MinHeight="48" Padding="4,0,8,0" CornerRadius="12"
|
||||
HorizontalAlignment="Left"
|
||||
AutomationProperties.Name="{Binding Vault.VaultName, StringFormat='Vault {0}. Choose vaults'}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Border Width="32" Height="32" CornerRadius="10" Background="{StaticResource AccentGradient}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text=">_" Foreground="{StaticResource AccentInk}"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="12" FontWeight="Bold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw" />
|
||||
</Border>
|
||||
<TextBlock Classes="heading" FontSize="18" FontWeight="Bold" MaxWidth="170"
|
||||
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="icon" Text="" FontSize="20" Foreground="{StaticResource TextFaint}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
The sync light, and it is green only when it has earned it — see SyncLabel. The design draws
|
||||
a permanently green "Synced" here, which is the one claim on that mock-up this application
|
||||
will not make.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="6" Height="6"
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center"
|
||||
Margin="0,0,6,0">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="8" Height="8"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="label" FontSize="9" Text="{Binding SyncLabel}" />
|
||||
<TextBlock FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding SyncLabel}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -145,16 +165,49 @@
|
||||
the bar it raises takes this header's place — so the pencil is in that bar, beside the count of
|
||||
what it would edit. See HostActionBar.axaml, which is drawn in the Panel below this one.
|
||||
|
||||
What is left in this row is the vault's name, the sync light and LOCK: three facts about the
|
||||
keychain, and none of them about a row.
|
||||
What is left in this row is the vault's name, the sync light and the lock: three facts about
|
||||
the keychain, and none of them about a row. The lock is an icon since the redesign, with its
|
||||
name on it for a screen reader rather than the word LOCK in nine-pixel capitals.
|
||||
-->
|
||||
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
||||
ToolTip.Tip="Lock the keychain">
|
||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
<Button Grid.Column="3" Classes="icon" Command="{Binding LockCommand}"
|
||||
AutomationProperties.Name="Lock the keychain" ToolTip.Tip="Lock the keychain">
|
||||
<TextBlock Classes="icon" Text="" FontSize="21" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ HOSTS · KEYCHAIN · SNIPPETS: the vault's three kinds of content, as one switch.
|
||||
|
||||
The redesign's answer to the audit's M8, that SETTINGS held the product. The keychain and the
|
||||
snippets were rows on the hub, a screen away from the hosts they are used with; they are the open
|
||||
vault's contents as much as the hosts are, so they now sit beside them under the VAULTS tab, and the
|
||||
hub keeps what is about this phone rather than about a vault.
|
||||
|
||||
Narrow surfaces only. A wide one has the rail, which lists all three already — see
|
||||
PhoneShell.ShowsVaultTabs. The chips are RadioButtons for the reason the keychain's own are: the
|
||||
three are exclusive and a screen reader should say so, and Mode=OneWay so the switch moves when the
|
||||
screen does and never ahead of it.
|
||||
-->
|
||||
<Border IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultTabs}" Padding="16,12,16,4">
|
||||
<Border Classes="segmented">
|
||||
<Grid ColumnDefinitions="*,*,*">
|
||||
<RadioButton Grid.Column="0" Classes="segment" GroupName="vaulttab" Content="Hosts"
|
||||
IsChecked="{Binding IsHostsShowing, Mode=OneWay}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
|
||||
<RadioButton Grid.Column="1" Classes="segment" GroupName="vaulttab" Content="Keychain"
|
||||
IsChecked="{Binding IsKeychainShowing, Mode=OneWay}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Keychain}" />
|
||||
<RadioButton Grid.Column="2" Classes="segment" GroupName="vaulttab" Content="Snippets"
|
||||
IsChecked="{Binding IsSnippetsShowing, Mode=OneWay}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ ◆ the contextual action bar ============ -->
|
||||
@@ -277,67 +330,14 @@
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
The shells strip. The design puts it above the nav on every screen, and that is the single most
|
||||
desktop-shaped idea in the product — it is what makes a terminal a surface you switch to rather than
|
||||
a screen you navigate away from. Kept, because losing it would make an open shell invisible from the
|
||||
host list, which is where you are when you open the next one.
|
||||
◆ THE SHELLS STRIP IS GONE, AND THE BADGE ON CONNECTIONS IS WHAT REPLACED IT.
|
||||
|
||||
v2 draws the sessions as pills rather than as a labelled row, and drops the word SHELLS: with a
|
||||
rounded chip carrying a live dot and a name, the label was spending nine characters of a 360dp row
|
||||
saying what the row already looks like.
|
||||
|
||||
On every screen except the one it names. The terminal draws these same sessions in its own bar, and
|
||||
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
|
||||
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
|
||||
whether there are any tabs and this one is about which surface is up.
|
||||
|
||||
◆ And it stands down under the host editor, which is a page rather than a card since the pencil moved
|
||||
into the action bar. A strip of open shells above a form is the same two-rows-of-chrome problem the
|
||||
terminal has, on a screen where the form is the whole point of being there. See
|
||||
PhoneShell.ShowsShellStrip, which is where that "and" is made, Avalonia's bindings having none.
|
||||
It sat above the bar on every page — the single most desktop-shaped idea in the product, kept so that
|
||||
an open shell was never invisible from the host list. The 2026-10-04 redesign keeps that promise with
|
||||
less: Connections carries the number of open sessions in a green badge, and one tap on it is the
|
||||
sessions themselves, in the terminal's own bar. That is 46 pixels of every page given back to the list,
|
||||
and one row of the same pills where there were two on the way into a shell.
|
||||
-->
|
||||
<Panel Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsShellStrip}">
|
||||
<!-- DeepChrome rather than Sidebar, since v5, joining the header and the bar above and below it:
|
||||
the strip is chrome the same way they are — a frame around the screen rather than a pane of
|
||||
it — and Sidebar is what a card sits on, which this row is not. -->
|
||||
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="10"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!--
|
||||
Green only while there is a shell behind the tab. It used to be lit unconditionally,
|
||||
which was true when a tab could not exist without a session; one can now —
|
||||
connecting opens the tab first — and a dot that was green before anything had
|
||||
answered would be the one thing on this strip claiming something untrue.
|
||||
|
||||
Amber while it is being made, which is the other half of that correction. Not being
|
||||
green stopped the dot lying, but it left a tab still dialling drawn exactly like a
|
||||
tab whose shell has exited — the two states on this strip with the least in common,
|
||||
one worth waiting for and one over. See Phone.axaml.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ navigation ============ -->
|
||||
<!--
|
||||
@@ -353,20 +353,26 @@
|
||||
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
|
||||
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
|
||||
|
||||
── three rather than four ─────────────────────────────────────────────────────────────────────────
|
||||
The keychain moved behind SETTINGS. A bottom bar is for the places a session moves between, and keys,
|
||||
credentials and tags are not that — they are managed occasionally and then left alone, which is the
|
||||
shape of everything else already behind the hub. What is left is the two halves of using this
|
||||
application, and the drawer holding the rest.
|
||||
── VAULTS · CONNECTIONS · SETTINGS ────────────────────────────────────────────────────────────────
|
||||
Three entries still, and the first is the open vault rather than its host list: hosts, the keychain
|
||||
and snippets are a switch at the top of it. The keychain had moved behind SETTINGS on the argument that
|
||||
a bottom bar is for the places a session moves between; the redesign keeps the bar at three and puts the
|
||||
vault's own contents back one tap from anywhere, which is what the audit's M8 asked for. 72 tall, for
|
||||
the pill and a 12-pixel label under it — see NavButton.
|
||||
-->
|
||||
<!-- DeepChrome rather than Chrome, since v5 — see the remark on the vault header, above, which this
|
||||
bar is the foot of the same frame the header is the top of. -->
|
||||
<Border Grid.Row="3" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
|
||||
<Border Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
|
||||
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Height="64">
|
||||
BorderThickness="0,1,0,0" Height="72">
|
||||
<Grid ColumnDefinitions="*,*,*">
|
||||
|
||||
<views:NavButton Grid.Column="0" Label="Hosts" Glyph="▤" IsCurrent="{Binding IsHostsShowing}"
|
||||
<!--
|
||||
IsVaultSurface rather than IsHostsShowing, for the reason SETTINGS reads IsMoreSurface: the tab
|
||||
stands for three screens, and it stays lit on whichever of them is up. A tap goes to the hosts,
|
||||
which is where the application opens and what the vault's switch starts on.
|
||||
-->
|
||||
<views:NavButton Grid.Column="0" Label="Vaults" Glyph="" IsCurrent="{Binding IsVaultSurface}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
|
||||
|
||||
@@ -386,9 +392,11 @@
|
||||
shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was
|
||||
collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the
|
||||
bar can now sit under and not point at is the entry looking broken instead.
|
||||
|
||||
◆ The count is the open tabs, and it is what the shells strip used to say. See the remark above.
|
||||
-->
|
||||
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
|
||||
IsCurrent="{Binding IsTerminalSurface}"
|
||||
<views:NavButton Grid.Column="1" Label="Connections" Glyph=""
|
||||
IsCurrent="{Binding IsTerminalSurface}" Count="{Binding Tabs.Count}"
|
||||
Command="{Binding ShowTerminalCommand}" />
|
||||
|
||||
<!--
|
||||
@@ -396,18 +404,32 @@
|
||||
and a bar that went dark the moment you opened one of them would only ever light two of its
|
||||
three entries.
|
||||
|
||||
"Settings" and a gear, where it was "More" and a hamburger. The hub held a handful of things
|
||||
that did not fit and could not be called anything better than More; with the keychain, teams and
|
||||
preferences all on it, it is the place everything that is not connecting to something lives —
|
||||
which is what a gear has meant on a phone since before this application existed.
|
||||
"Settings" and a gear, where it was "More" and a hamburger. With the vault's contents moved under
|
||||
VAULTS, what is behind it is about this phone and this account — security, the terminal's
|
||||
behaviour, which vaults are shown, the logs — which is what a gear has meant on a phone since
|
||||
before this application existed.
|
||||
-->
|
||||
<views:NavButton Grid.Column="2" Label="Settings" Glyph="⚙" IsCurrent="{Binding IsMoreSurface}"
|
||||
<views:NavButton Grid.Column="2" Label="Settings" Glyph="" IsCurrent="{Binding IsMoreSurface}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ THE REST OF THE SCRIM, over the two rows of chrome a sheet on the hosts screen cannot reach.
|
||||
|
||||
The hosts screen's sheets are drawn inside the screen's own row, so their scrim dimmed the list and
|
||||
left the header — with the lock on it — and the bottom bar bright and live above and below every
|
||||
sheet (audit M12). These two finish the job in the same colour, declared last so they draw over
|
||||
the header and the bar, and a tap on either does what a tap on the sheet's own scrim does: lowers
|
||||
the sheet. See PhoneShell.ShowsSheetScrim and OnChromeScrimTapped.
|
||||
-->
|
||||
<Button Grid.Row="0" Classes="scrim" IsVisible="{Binding $parent[views:PhoneShell].ShowsSheetScrim}"
|
||||
Click="OnChromeScrimTapped" AutomationProperties.Name="Close the sheet" />
|
||||
<Button Grid.Row="2" Classes="scrim" IsVisible="{Binding $parent[views:PhoneShell].ShowsSheetScrim}"
|
||||
Click="OnChromeScrimTapped" AutomationProperties.Name="Close the sheet" />
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -43,6 +43,13 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary>
|
||||
private IInputPane? keyboard;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the software keyboard is up, read from the pane's state rather than from the inset: before
|
||||
/// Android 15 the window itself shrinks for the keyboard and the inset reads zero. See
|
||||
/// <see cref="ApplyKeyboardInset(double)"/>.
|
||||
/// </summary>
|
||||
private bool keyboardOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the lock screen currently showing is the one the application launched into.
|
||||
/// </summary>
|
||||
@@ -89,6 +96,7 @@ internal sealed partial class PhoneShell : UserControl
|
||||
if (shell is not null)
|
||||
{
|
||||
shell.PropertyChanged -= OnShellChanged;
|
||||
shell.Transfers.PropertyChanged -= OnVaultChanged;
|
||||
}
|
||||
|
||||
shell = DataContext as MainWindowViewModel;
|
||||
@@ -96,6 +104,11 @@ internal sealed partial class PhoneShell : UserControl
|
||||
if (shell is not null)
|
||||
{
|
||||
shell.PropertyChanged += OnShellChanged;
|
||||
|
||||
// The files screen's actions sheet is the one sheet outside the hosts screen that the chrome
|
||||
// scrim covers for, so its openness is heard here too. Transfers is built once with the shell
|
||||
// and never replaced, unlike the vault.
|
||||
shell.Transfers.PropertyChanged += OnVaultChanged;
|
||||
ApplyScreenshotPolicy(shell.State);
|
||||
|
||||
thisLockIsTheLaunch = true;
|
||||
@@ -170,9 +183,13 @@ internal sealed partial class PhoneShell : UserControl
|
||||
public static readonly StyledProperty<bool> ShowsHostSelectionBarProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsHostSelectionBar));
|
||||
|
||||
/// <summary>Whether the strip of open shells above the bottom bar is drawn.</summary>
|
||||
public static readonly StyledProperty<bool> ShowsShellStripProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsShellStrip));
|
||||
/// <summary>Whether the HOSTS · KEYCHAIN · SNIPPETS switch is drawn under the vault's header.</summary>
|
||||
/// <remarks>
|
||||
/// Narrow surfaces only: a wide one has the rail, which lists the three already, and a switch repeating
|
||||
/// three of the rail's entries an inch to the right of them would be the same choice offered twice.
|
||||
/// </remarks>
|
||||
public static readonly StyledProperty<bool> ShowsVaultTabsProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultTabs));
|
||||
|
||||
/// <inheritdoc cref="IsWideProperty" />
|
||||
public bool IsWide
|
||||
@@ -209,11 +226,22 @@ internal sealed partial class PhoneShell : UserControl
|
||||
private set => SetValue(ShowsHostSelectionBarProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ShowsShellStripProperty" />
|
||||
public bool ShowsShellStrip
|
||||
/// <summary>Whether the header and the bottom bar are dimmed under a sheet the hosts screen raised.</summary>
|
||||
public static readonly StyledProperty<bool> ShowsSheetScrimProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsSheetScrim));
|
||||
|
||||
/// <inheritdoc cref="ShowsSheetScrimProperty" />
|
||||
public bool ShowsSheetScrim
|
||||
{
|
||||
get => GetValue(ShowsShellStripProperty);
|
||||
private set => SetValue(ShowsShellStripProperty, value);
|
||||
get => GetValue(ShowsSheetScrimProperty);
|
||||
private set => SetValue(ShowsSheetScrimProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ShowsVaultTabsProperty" />
|
||||
public bool ShowsVaultTabs
|
||||
{
|
||||
get => GetValue(ShowsVaultTabsProperty);
|
||||
private set => SetValue(ShowsVaultTabsProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -243,6 +271,13 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// Losing them on the keychain would be losing the only LOCK button on the surface.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Narrow, it is the VAULTS tab's header now, on all three of its screens.</b> The keychain and
|
||||
/// snippets moved from behind SETTINGS to the vault's own switch (see
|
||||
/// <see cref="MainWindowViewModel.IsVaultSurface"/>), so the header and the switch under it are drawn
|
||||
/// over hosts, keychain and snippets alike, and those two screens' own headers stand down on a narrow
|
||||
/// surface to make room.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>The header is now a swap rather than a switch, and the editor takes the whole screen.</b> Two
|
||||
/// more flags and two more inputs, both of them the vault's rather than the shell's — see
|
||||
/// <see cref="vault"/>. While hosts are ticked the header stands down and
|
||||
@@ -272,9 +307,11 @@ internal sealed partial class PhoneShell : UserControl
|
||||
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
|
||||
|
||||
// The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
|
||||
// not the shells strip, and not the way off the screen. Its own header carries the back arrow, which
|
||||
// not the vault's switch, and not the way off the screen. Its own header carries the back arrow, which
|
||||
// is the one control it needs and the one the system gesture already maps to.
|
||||
var editing = vault?.IsEditing == true;
|
||||
// The keychain's editor page counts too: it is the host editor's shape and wants the same screen.
|
||||
var editing = vault?.IsEditing == true
|
||||
|| (vault?.IsEditingAVaultItem == true && shell?.IsKeychainShowing == true);
|
||||
|
||||
// Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by
|
||||
// navigating — and a bar counting hosts over the transfers screen would be chrome about a list that
|
||||
@@ -293,10 +330,17 @@ internal sealed partial class PhoneShell : UserControl
|
||||
|
||||
IsWide = wide;
|
||||
ShowsRail = wide && (pages || connectPage) && !editing;
|
||||
ShowsBottomBar = !wide && (pages || connectPage) && !editing;
|
||||
ShowsShellStrip = pages && !editing;
|
||||
// ◆ And not while the software keyboard is up. The body shrinks to stay clear of the keyboard, so the
|
||||
// bar rode up on top of it and took 72 pixels from whatever was being typed into — the connect sheet,
|
||||
// most often (audit M12). Every Android app with a bottom bar puts it away for the keyboard.
|
||||
ShowsBottomBar = !wide && (pages || connectPage) && !editing && !keyboardOpen;
|
||||
ShowsHostSelectionBar = pages && choosing && !editing;
|
||||
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
|
||||
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsVaultSurface == true);
|
||||
ShowsVaultTabs = ShowsVaultHeader && !wide;
|
||||
ShowsSheetScrim = pages
|
||||
&& ((vault is not null && HasASheetUp(vault))
|
||||
|| (shell is { Transfers.IsEntryMenuOpen: true }
|
||||
&& (shell.IsTransfersShowing || shell.IsBucketsShowing)));
|
||||
}
|
||||
|
||||
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
@@ -452,10 +496,18 @@ internal sealed partial class PhoneShell : UserControl
|
||||
DispatcherPriority.Background);
|
||||
|
||||
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
||||
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
|
||||
{
|
||||
keyboardOpen = e.NewState is InputPaneState.Open;
|
||||
ApplyKeyboardInset(keyboardOpen ? e.EndRect.Height : 0);
|
||||
RefreshChrome();
|
||||
}
|
||||
|
||||
private void ApplyKeyboardInset(IInputPane pane)
|
||||
=> ApplyKeyboardInset(pane.State is InputPaneState.Open ? pane.OccludedRect.Height : 0);
|
||||
{
|
||||
keyboardOpen = pane.State is InputPaneState.Open;
|
||||
ApplyKeyboardInset(keyboardOpen ? pane.OccludedRect.Height : 0);
|
||||
RefreshChrome();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds the phone's whole interface clear of the software keyboard.
|
||||
@@ -568,7 +620,7 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// application opens on, which is what every other Android app does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>v3 adds one guard above the switch rather than another case inside it.</b> The switch's first case
|
||||
/// <b>v3 adds one guard above the switch rather than another case inside it.</b> The switch's second case
|
||||
/// is the membership test of <see cref="MainWindowViewModel.IsMoreSurface"/> minus <c>More</c> itself,
|
||||
/// and has to stay in step with it — a screen added to the hub and not to that case would trap the user
|
||||
/// on it. An editor is not a screen and has no entry there. It is also strictly nearer: the add sheet
|
||||
@@ -608,12 +660,30 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
// The files screen's actions sheet, raised from its selection bar.
|
||||
if (current.Transfers.IsEntryMenuOpen)
|
||||
{
|
||||
current.Transfers.IsEntryMenuOpen = false;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryCloseAnOpenEditor(current))
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// The Connections prompt's password line, which back steps off onto the name it asked about — the
|
||||
// same thing its BACK button does, and the nearer of the two things back could mean here. Leaving the
|
||||
// surface would throw the name away along with the question.
|
||||
if (current is { IsConnectPromptShowing: true, IsPromptAskingForPassword: true })
|
||||
{
|
||||
current.CancelPromptCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!current.IsShowingPages)
|
||||
{
|
||||
current.ShowScreenCommand.Execute(current.Screen);
|
||||
@@ -621,28 +691,43 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryGoUp(current))
|
||||
{
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Moves one level up the screens, for the back gesture.</summary>
|
||||
/// <returns>Whether it moved, and so whether back has been spent.</returns>
|
||||
/// <remarks>See <see cref="OnBackRequested"/>, whose last step this is.</remarks>
|
||||
private bool TryGoUp(MainWindowViewModel current)
|
||||
{
|
||||
switch (current.Screen)
|
||||
{
|
||||
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
|
||||
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults
|
||||
or ShellScreen.Keychain:
|
||||
// ◆ The vault's other two screens go back to its hosts rather than to the hub, since the redesign
|
||||
// moved them under VAULTS: the switch at the top of the vault is where they were reached from, and
|
||||
// HOSTS is where that switch starts.
|
||||
case ShellScreen.Keychain or ShellScreen.Snippets:
|
||||
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
||||
return true;
|
||||
|
||||
case ShellScreen.Logs or ShellScreen.Transfers
|
||||
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults:
|
||||
|
||||
// ◆ The hub, or Hosts where there is no hub. A wide surface draws the rail instead and
|
||||
// never draws SETTINGS, so backing out to it would land on a screen with no way off it but
|
||||
// a second back — and the arrows in these screens' own headers are hidden there for the
|
||||
// same reason. Home is where back goes when the thing you came from is not on the surface.
|
||||
current.ShowScreenCommand.Execute(IsWide ? ShellScreen.Hosts : ShellScreen.More);
|
||||
e.Handled = true;
|
||||
break;
|
||||
return true;
|
||||
|
||||
case ShellScreen.More:
|
||||
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
||||
e.Handled = true;
|
||||
break;
|
||||
return true;
|
||||
|
||||
default:
|
||||
// Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app.
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,6 +775,25 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Whether one of the hosts screen's four sheets is up.</summary>
|
||||
/// <remarks>The same four <see cref="TryLowerASheet"/> lowers, in the same terms.</remarks>
|
||||
private static bool HasASheetUp(VaultViewModel vault) =>
|
||||
vault.IsHostActionSheetOpen || vault.IsAskingForConnectPassword || vault.IsAddSheetOpen
|
||||
|| vault.GroupSheet is not null || vault.IsKeychainAddSheetOpen;
|
||||
|
||||
/// <summary>A tap on the scrim over the header or the bar: lowers the sheet, as its own scrim does.</summary>
|
||||
private void OnChromeScrimTapped(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (shell is { Transfers.IsEntryMenuOpen: true })
|
||||
{
|
||||
shell.Transfers.IsEntryMenuOpen = false;
|
||||
}
|
||||
else if (shell?.Vault is { } open)
|
||||
{
|
||||
TryLowerASheet(open);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lowers whatever the hosts screen has raised over its list, topmost first.
|
||||
/// </summary>
|
||||
@@ -755,6 +859,12 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vault.IsKeychainAddSheetOpen)
|
||||
{
|
||||
vault.CloseKeychainAddSheetCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vault.GroupSheet is not null)
|
||||
{
|
||||
vault.CloseGroupSheetCommand.Execute(null);
|
||||
@@ -792,6 +902,11 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return true;
|
||||
}
|
||||
|
||||
if (KeychainScreen.TryCancelAnEditor(vault))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vault.IsEditingGroup)
|
||||
{
|
||||
vault.CancelGroupEditCommand.Execute(null);
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<!-- The lock screen draws the same ring beside its own fingerprint button. -->
|
||||
<Ellipse Width="16" Height="16" Stroke="{StaticResource Accent}" StrokeThickness="1.5"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Unlock with a fingerprint" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Unlock with a fingerprint" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="body"
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
<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}"
|
||||
<TextBlock Classes="detail code" 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"
|
||||
@@ -247,7 +247,7 @@
|
||||
<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}"
|
||||
<TextBlock Grid.Column="1" Classes="detail code" FontSize="10" Text="{Binding Host}"
|
||||
Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
@@ -27,18 +27,14 @@
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Height="56" Margin="8,0">
|
||||
<!--
|
||||
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
|
||||
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
|
||||
somewhere the layout has removed. See PhoneShell.IsWide.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="icon" Content="←"
|
||||
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Text="Snippets" Margin="4,0" />
|
||||
<Button Grid.Column="2" Classes="icon accent" Content="+" Command="{Binding NewCommand}" />
|
||||
<!--
|
||||
◆ Wide surfaces only, since the 2026-10-04 redesign, for the reason the keychain's header is: on a narrow
|
||||
one this screen is the SNIPPETS third of the open vault's switch, under the vault's own header. The back
|
||||
arrow went with it, and the + moved to the floating button below — the one shape every add on this head
|
||||
now takes (audit M10), where a header + here meant "new snippet" and the terminal's meant "new session".
|
||||
-->
|
||||
<Grid Grid.Row="0" Height="56" Margin="12,0" IsVisible="{Binding $parent[views:PhoneShell].IsWide}">
|
||||
<TextBlock Classes="heading" Text="Snippets" Margin="4,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ filter ============ -->
|
||||
@@ -103,9 +99,9 @@
|
||||
Text="Left off, inserting types the command at the prompt and stops, so it can be read and edited before it runs. Nothing here can tell whether the terminal is even at a prompt, which is why that is the default." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="SAVE"
|
||||
Command="{Binding SaveCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -119,27 +115,41 @@
|
||||
<TextBlock Classes="body" IsVisible="{Binding !HasVisible}" Margin="24,20" VerticalAlignment="Top"
|
||||
Text="{Binding EmptyMessage}" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Visible}" SelectedItem="{Binding Selected}"
|
||||
<!-- The foot padding is the floating button's: the last card scrolls clear of it. -->
|
||||
<ListBox ItemsSource="{Binding Visible}" SelectedItem="{Binding Selected}" Padding="0,4,0,88"
|
||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding HasVisible}">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<!-- A name for TalkBack rather than the row view model's type name (audit H4). -->
|
||||
<Setter Property="AutomationProperties.Name" Value="{ReflectionBinding Label}" />
|
||||
</Style>
|
||||
<!--
|
||||
On the card, as the keychain does it and for the keychain's reason: Border.card paints an opaque
|
||||
surface over the item, so a fill behind it would never show. This list used to be lit by Fluent's
|
||||
own selection blue showing round the card's edges; the theme no longer draws that (audit M3), and
|
||||
the chosen snippet is the one the bar under the list acts on, so it has to look chosen.
|
||||
-->
|
||||
<Style Selector="ListBoxItem:selected Border.card">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<Border Classes="card" Margin="12,5">
|
||||
<StackPanel Spacing="9">
|
||||
<!--
|
||||
◆ The redesign's snippet card: the name in the sans face, the command under it in the mono, and
|
||||
what a tap on INSERT will do in a badge on the right — RUNS for one that presses Enter, INSERTS
|
||||
for one that waits at the prompt to be read. Both are said, rather than only the first: a card
|
||||
with no badge was the safe one, and the safe one is the one worth naming too.
|
||||
-->
|
||||
<Border Classes="card" Margin="16,4" Padding="14,12">
|
||||
<Grid ColumnDefinitions="*,10,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="5">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Label}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<!-- ◆ The chip that says this one does not wait to be read. -->
|
||||
<Border Classes="tag" IsVisible="{Binding RunsOnInsert}"
|
||||
Background="{StaticResource WarnWash}">
|
||||
<TextBlock Text="RUNS" Foreground="{StaticResource Warn}" />
|
||||
</Border>
|
||||
<TextBlock FontSize="15" FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||
Text="{Binding Label}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<Border Classes="tag outline"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
@@ -156,22 +166,39 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- The command, on the surface every block of monospace in this design is drawn on.
|
||||
No radius override any more: it diverged from Border.output's own 14 under v2 for a
|
||||
smaller card-nested block, and now that the style's own value has moved to 12 there is
|
||||
no gap left worth a second number for — it takes the class's, like every other output
|
||||
block on this head. -->
|
||||
<Border Classes="output" Padding="11,9">
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Preview}"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
<!-- The command, one line of mono under the name. It was a block of its own on the terminal's
|
||||
surface; the redesign keeps the card to two lines so four of them fit where two did. -->
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Preview}"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ◆ The badge that says whether this one waits to be read. Amber for the one that does not. -->
|
||||
<Border Grid.Column="2" Classes="tag" IsVisible="{Binding RunsOnInsert}"
|
||||
Background="{StaticResource WarnWash}">
|
||||
<TextBlock Text="RUNS" Foreground="{StaticResource Warn}" />
|
||||
</Border>
|
||||
<Border Grid.Column="2" Classes="tag outline" IsVisible="{Binding !RunsOnInsert}">
|
||||
<TextBlock Text="INSERTS" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ add ============ -->
|
||||
<!--
|
||||
◆ The floating button, where the header's + was: every add on this head is this one shape now — hosts,
|
||||
keychain, snippets — and it is where a thumb already is. Not while the editor is open, which is the
|
||||
add it would raise.
|
||||
-->
|
||||
<Button Grid.Row="3" Classes="fab" Margin="0,0,18,18" HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
AutomationProperties.Name="New snippet" IsVisible="{Binding !IsEditing}"
|
||||
Command="{Binding NewCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="26" />
|
||||
</Button>
|
||||
|
||||
<!-- ============ what to do with the chosen one ============ -->
|
||||
<!--
|
||||
Raised over the list, as the hosts screen raises its connect bar, and for the same reason: there is no
|
||||
@@ -242,9 +269,9 @@
|
||||
<TextBlock Classes="body"
|
||||
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="MOVE"
|
||||
Command="{Binding ConfirmMoveCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
@@ -178,8 +178,10 @@
|
||||
the 44 that matters is the horizontal one, since nothing in a row of this shape is hard to hit
|
||||
above or below.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="icon" Content="←" Height="34" Margin="4,0,0,0"
|
||||
Command="{Binding ShowScreenCommand}" CommandParameter="{Binding Screen}" />
|
||||
<Button Grid.Column="0" Classes="icon" Height="34" Margin="4,0,0,0" AutomationProperties.Name="Back"
|
||||
Command="{Binding ShowScreenCommand}" CommandParameter="{Binding Screen}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="20" />
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
v2 draws these as pills rather than as a segmented strip, so the row is transparent and each
|
||||
@@ -206,7 +208,7 @@
|
||||
BorderThickness="1" CornerRadius="10" Height="30">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="10"
|
||||
VerticalContentAlignment="Center"
|
||||
VerticalContentAlignment="Center" AutomationProperties.Name="{Binding Label}"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
@@ -239,13 +241,13 @@
|
||||
to look like.
|
||||
-->
|
||||
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,10,10,0"
|
||||
AutomationProperties.Name="{Binding Label, StringFormat='Close {0}'}"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Text="×" Foreground="{StaticResource TextFaint}" FontSize="14"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
<TextBlock Classes="icon" Text="" Foreground="{StaticResource TextFaint}"
|
||||
FontSize="16" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
@@ -301,9 +303,12 @@
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="3" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
|
||||
<Button Grid.Column="3" Classes="icon" Height="34" Margin="0,0,4,0"
|
||||
AutomationProperties.Name="Connect, or open this host's files"
|
||||
Click="OnSheetRequested"
|
||||
Command="{Binding OpenConnectSheetCommand}" />
|
||||
Command="{Binding OpenConnectSheetCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
The wide screen's side panel, shown and hidden — the quick-access keyboard beside the terminal
|
||||
@@ -340,9 +345,11 @@
|
||||
running this surface is where a connection is made rather than a page apologising for having
|
||||
nothing on it. Two things are offered, in the order somebody reaches for them.
|
||||
|
||||
A box, because the case this application had no answer for is the address somebody has just been
|
||||
given. Everything else here starts from a keychain item, which is right for a machine used twice and
|
||||
is several screens of work for one used once.
|
||||
A prompt, because a terminal with nothing in it should do what a terminal does, which is ask — the
|
||||
same one the desktop draws in its empty terminal, over the same view-model state. It takes a saved
|
||||
host's name or user@host for a machine that is not saved, and asks for a password only when one is
|
||||
needed. See ConnectPrompt.axaml. It replaced a box that took addresses alone, which left a saved
|
||||
machine several screens away from here.
|
||||
|
||||
And the machines already connected to, under it, because that is the commoner half by far: the
|
||||
thing most likely to be wanted is the thing that was wanted yesterday. They come out of the vault's
|
||||
@@ -354,44 +361,7 @@
|
||||
|
||||
<TextBlock Classes="title" FontSize="13" Text="CONNECT TO A MACHINE" />
|
||||
|
||||
<!--
|
||||
The manual target. One box in the grammar people already have — user@host, or user@host:port —
|
||||
rather than three, because three boxes on a phone is three taps between three keyboards for
|
||||
something that arrives as one string.
|
||||
-->
|
||||
<TextBox Classes="field" Text="{Binding Vault.ManualTarget}"
|
||||
PlaceholderText="user@host or user@host:port">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<!--
|
||||
A typed password and nothing else. Offering the keychain's keys here would be a second binding
|
||||
resolution beside the one the connect path already has, and a key is the reason to save the
|
||||
machine as a host rather than dial it from a box.
|
||||
|
||||
Classes="secret" and not merely PasswordChar: the class is what also tells the software keyboard
|
||||
this is not prose, which is the difference between dots on screen and dots on screen with the
|
||||
password in the IME's dictionary. See Theme/Phone.axaml.
|
||||
-->
|
||||
<TextBox Classes="field secret" Text="{Binding Vault.ManualPassword}"
|
||||
PlaceholderText="password">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<Button Classes="primary" Content="CONNECT" Command="{Binding Vault.ConnectManuallyCommand}" />
|
||||
|
||||
<!--
|
||||
Beside the box rather than on the shell's status line. What fails here is about what was typed —
|
||||
a missing account, a port that is not a number — and a sentence about a text box belongs under
|
||||
the text box.
|
||||
-->
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding Vault.ManualStatus}"
|
||||
IsVisible="{Binding Vault.ManualStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<views:ConnectPrompt />
|
||||
|
||||
<TextBlock Classes="body"
|
||||
Text="Nothing typed here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name." />
|
||||
@@ -405,20 +375,19 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<!--
|
||||
Tapping one does not connect, and that is deliberate rather than a missing step. A row
|
||||
that names a keychain host goes to that host with its connect bar — which is where its
|
||||
key, or its password box, or its refusal lives — and one that names only an address goes
|
||||
back into the box above, without the password, which was never stored. Either way what
|
||||
happens next is visible before anything is dialled. See ConnectToRecentCommand.
|
||||
A tap goes onto the prompt above and carries on as if it had been typed: a keychain host
|
||||
connects, or asks for its password there; an address asks for its password, which was
|
||||
never stored. Nothing is dialled before the prompt has asked whatever it needs to. See
|
||||
MainWindowViewModel.ChoosePromptRecentCommand.
|
||||
-->
|
||||
<Button Classes="row" MinHeight="52" Padding="12,8" CornerRadius="10"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ConnectToRecentCommand}"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ChoosePromptRecentCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||
<TextBlock Classes="detail code" FontSize="10.5" Text="{Binding Address}"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
|
||||
@@ -760,10 +729,32 @@
|
||||
|
||||
<TextBlock Classes="title" Text="CONNECT" FontSize="13" />
|
||||
|
||||
<Button Classes="row" Margin="0,10,0,0" Command="{Binding ShowScreenCommand}"
|
||||
<!--
|
||||
◆ This shell's own host's files, first: the redesign draws a session's terminal and its files as
|
||||
two views of one thing, and this is the nearest this application comes. It opens SFTP to the host
|
||||
the selected tab is on — the desktop sidebar's "Open SFTP", through the same command — so the
|
||||
listing lands on the right machine without choosing it again from a picker. It is a second
|
||||
connection, which is why it is offered rather than switched to; see
|
||||
MainWindowViewModel.SelectFilesHostAsync.
|
||||
-->
|
||||
<Button Classes="row" Margin="0,10,0,0" Command="{Binding OpenOtherSurfaceCommand}"
|
||||
IsVisible="{Binding SelectedTab, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
AutomationProperties.Name="Files on this host">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Files on this host" />
|
||||
<TextBlock Classes="detail code" Text="{Binding SelectedTab.Address, FallbackValue=''}"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Connect" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="Another shell, on this host or any other in the keychain." />
|
||||
</StackPanel>
|
||||
@@ -779,7 +770,7 @@
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect via SFTP" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Connect via SFTP" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="Browse a host's files." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -787,7 +778,7 @@
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect via S3" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Connect via S3" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="Objects in an S3-compatible bucket from the keychain." />
|
||||
</StackPanel>
|
||||
@@ -1006,7 +997,7 @@
|
||||
<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}"
|
||||
<TextBlock Grid.Column="1" Classes="detail code" 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}">
|
||||
|
||||
@@ -113,9 +113,9 @@
|
||||
Text="Its key is made on this phone and nobody else has it. Add people to it once it exists, then share the key with them." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="CREATE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="CREATE"
|
||||
Command="{Binding CreateVaultCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelNewVaultCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
@@ -214,7 +214,17 @@
|
||||
ContentPresenter for Background for the Fluent-override reason explained above, and ContentPresenter
|
||||
carries its own BoxShadow property in Avalonia 11, so the glow and the fill live on the same element
|
||||
and move together.
|
||||
|
||||
◆ ClipToBounds on the Button, because Fluent's own Button theme sets it to True — and a glow is
|
||||
drawn outside the control by definition. With the clip on, the 22-pixel blur was cut at the button's
|
||||
square bounds and showed as a faint rectangle behind the rounded fill: the "slightly lighter square"
|
||||
the 2026-10-02 Android audit measured (M1), which this head shares because the pattern is the same.
|
||||
Measured in the layout harness rather than assumed: Button.ClipToBounds reads True under the theme,
|
||||
and the same button with it cleared renders the full glow.
|
||||
-->
|
||||
<Style Selector="Button.accent">
|
||||
<Setter Property="ClipToBounds" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||
<Setter Property="BoxShadow" Value="{StaticResource AccentGlow}" />
|
||||
@@ -863,6 +873,29 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ THE EMPTY TERMINAL'S PROMPT LINES, which are typed into the way a shell is: no fill, no border and no
|
||||
padding in any state, so the text sits on the pane exactly where the label before it leaves off, and the
|
||||
caret is the only sign the line takes input. A box drawn around it would turn a prompt back into a form.
|
||||
See ConnectPrompt.axaml. After the base rules and .pathbox, for the reason this file keeps restating.
|
||||
-->
|
||||
<Style Selector="TextBox.prompt">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.prompt /template/ Border#PART_BorderElement,
|
||||
TextBox.prompt:pointerover /template/ Border#PART_BorderElement,
|
||||
TextBox.prompt:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
||||
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
||||
@@ -1190,10 +1223,14 @@
|
||||
The two buttons in the drawer's header: the overflow menu and the arrow that puts the drawer away.
|
||||
Chrome rather than controls — they are the frame around whichever panel is showing, which is why they
|
||||
are flat and quiet until the pointer finds them.
|
||||
|
||||
No padding, because the box is fixed: Fluent's own 8,5,8,6 left a glyph ten pixels of width inside
|
||||
twenty-six, and every 15px Material icon on the SFTP panes was drawn with its sides cut off.
|
||||
-->
|
||||
<Style Selector="Button.paneicon">
|
||||
<Setter Property="Width" Value="26" />
|
||||
<Setter Property="Height" Value="26" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
@@ -1332,6 +1369,19 @@
|
||||
<Style Selector="TextBlock.qcrow-subtext">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<!--
|
||||
The palette's typed-address row, which sits above the list rather than in it and so has no ListBoxItem
|
||||
to take :pointerover and :selected from. The same two states, on the row itself.
|
||||
-->
|
||||
<Style Selector="Border.qcrow.adhoc:pointerover">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
<Style Selector="Border.qcrow.adhoc.selected, Border.qcrow.adhoc.selected:pointerover">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="Border.qcrow.adhoc.selected TextBlock.qcrow-text, Border.qcrow.adhoc.selected TextBlock.qcrow-subtext">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected TextBlock.qcrow-text, ListBoxItem:selected TextBlock.qcrow-subtext">
|
||||
<!--
|
||||
AccentInk rather than a literal white: it is v5's name for "the colour text sits in on top of the
|
||||
|
||||
@@ -127,17 +127,61 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
||||
this.installedVersion = installedVersion;
|
||||
this.handToInstaller = handToInstaller;
|
||||
|
||||
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal)
|
||||
? "dodossh-nightly"
|
||||
: "dodossh";
|
||||
packageName = PackageFor(channel);
|
||||
}
|
||||
|
||||
/// <summary>The channel for a copy under the RPM's install root, if rpm says the package is there.</summary>
|
||||
internal static IUpdateChannel ForInstall(string channel) =>
|
||||
ForInstall(channel, InstalledRpmVersion, () => new LinuxUpdateChannel(channel));
|
||||
|
||||
/// <summary>
|
||||
/// The channel for a copy under the RPM's install root, with rpm supplied, which is how the tests
|
||||
/// reach it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The path says where this copy is and only rpm says who put it there.</b>
|
||||
/// <c>UpdateChannels.LinuxChannel</c> has already established the first, and on an RPM distribution
|
||||
/// that is the whole story. It is not on any other: a locally built pacman or dpkg package that mirrors
|
||||
/// the spec puts the same tree at the same path, and this channel then offered every nightly as newer
|
||||
/// than a version nobody could read, fetched a package that machine has no way to install, and did it
|
||||
/// again every six hours.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asked once, at composition, for the reason the rest of this decision is: who installed this copy
|
||||
/// does not change while it runs. It costs one <c>rpm</c> launch at start on a machine whose path
|
||||
/// already matched, and nothing at all anywhere else.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="channel">The build's channel, which names the package.</param>
|
||||
/// <param name="installedVersion">What rpm says is installed under a package name, or null.</param>
|
||||
/// <param name="whenOwned">The channel to use when rpm does own this copy.</param>
|
||||
internal static IUpdateChannel ForInstall(
|
||||
string channel,
|
||||
Func<string, string?> installedVersion,
|
||||
Func<IUpdateChannel> whenOwned)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(channel);
|
||||
ArgumentNullException.ThrowIfNull(installedVersion);
|
||||
ArgumentNullException.ThrowIfNull(whenOwned);
|
||||
|
||||
var package = PackageFor(channel);
|
||||
|
||||
return installedVersion(package) is null
|
||||
? new UnavailableUpdateChannel(
|
||||
$"This copy of DodoSSH was installed outside the {package} RPM — rpm has no record of that "
|
||||
+ "package here — and an RPM is the only kind of update it can fetch. Update it with the "
|
||||
+ "package manager that installed it.")
|
||||
: whenOwned();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// True unconditionally, because whether this copy is one that can be updated was already decided by
|
||||
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class
|
||||
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at
|
||||
/// composition rather than at every call site.
|
||||
/// <see cref="UpdateChannels.ForThisMachine"/> and <see cref="ForInstall(string)"/> — a build run from
|
||||
/// a checkout, or installed by something other than rpm, never reaches this class at all. The same
|
||||
/// split Velopack's side uses, where <c>IsInstalled</c> is checked once at composition rather than at
|
||||
/// every call site.
|
||||
/// </remarks>
|
||||
public bool IsSupported => true;
|
||||
|
||||
@@ -184,9 +228,20 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
||||
$"The {channel} release's {manifestName} could not be read as a manifest.");
|
||||
}
|
||||
|
||||
// ◆ No answer from rpm is a failure, never a version. ForInstall only built this class because
|
||||
// rpm answered, so silence now is a package removed under a running process or an rpm that
|
||||
// stopped working — and any stand-in number would make every published build look newer and
|
||||
// fetch a package for a machine that cannot install it, which is what this once did.
|
||||
if (installedVersion(packageName) is not { } installed)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"rpm has no record of {packageName}, so there is no installed version to compare the "
|
||||
+ $"{channel} release against.");
|
||||
}
|
||||
|
||||
// The one place null is returned, and it means what null is documented to mean: this build is
|
||||
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
|
||||
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0)
|
||||
if (RpmVersion.Compare(manifest.RpmVersion, installed) <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -324,6 +379,12 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
||||
? $"{RepositoryApi}/releases/tags/nightly-desktop"
|
||||
: $"{RepositoryApi}/releases/latest";
|
||||
|
||||
/// <summary>The package a channel's builds are published as.</summary>
|
||||
private static string PackageFor(string channel) =>
|
||||
string.Equals(channel, "nightly", StringComparison.Ordinal)
|
||||
? "dodossh-nightly"
|
||||
: "dodossh";
|
||||
|
||||
private static string? Asset(LinuxForgeRelease? release, string name) =>
|
||||
release?.Assets?.FirstOrDefault(
|
||||
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
|
||||
@@ -365,9 +426,11 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
||||
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every
|
||||
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then
|
||||
/// an install PackageKit declines rather than a security fix that never arrives.
|
||||
/// A machine that cannot answer gets null — no <c>rpm</c> at all, or one with no such package — and
|
||||
/// null means "rpm does not own this copy", never a version. It used to be read as <c>0</c>, on the
|
||||
/// argument that the worst case was an install PackageKit declined. That holds only where PackageKit
|
||||
/// installs RPMs: on any other distribution it made every published build look newer, forever, and
|
||||
/// fetched a package that machine could not install. See <see cref="ForInstall(string)"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static string? InstalledRpmVersion(string packageName)
|
||||
|
||||
@@ -66,21 +66,23 @@ internal static class UpdateChannels
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
|
||||
/// <summary>The Linux channel, if this copy is one the RPM put here.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
|
||||
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
|
||||
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
|
||||
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
|
||||
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
|
||||
/// out.
|
||||
/// lives and then asking rpm.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the
|
||||
/// spec puts it there — so a build running from anywhere else is a checkout somebody started, an
|
||||
/// extracted archive, or a tree copied by hand. None of those can be updated by installing a package
|
||||
/// over them, and offering it would be an offer that cannot be carried out.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
|
||||
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
|
||||
/// differently. The install root is a constant in two places — here and the packaging script — and
|
||||
/// they are named in each other's comments.
|
||||
/// <b>The path is necessary and not sufficient.</b> It used to be the whole check, on the argument
|
||||
/// that asking rpm cost a process launch for a distinction nothing acted on differently. Something
|
||||
/// does: a locally built pacman package that mirrors the spec puts the same tree at the same path,
|
||||
/// and that copy was offered every nightly as an RPM it could never install. So a matching path is
|
||||
/// then put to rpm — see <see cref="LinuxUpdateChannel.ForInstall(string)"/> — and the launch is paid
|
||||
/// only where the path already matched. The install root is a constant in two places — here and the
|
||||
/// packaging script — and they are named in each other's comments.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("linux")]
|
||||
@@ -91,7 +93,7 @@ internal static class UpdateChannels
|
||||
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
|
||||
|
||||
return string.Equals(here, root, StringComparison.Ordinal)
|
||||
? new LinuxUpdateChannel(DesktopChannel.Name)
|
||||
? LinuxUpdateChannel.ForInstall(DesktopChannel.Name)
|
||||
: new UnavailableUpdateChannel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.ConnectPrompt"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Background="{StaticResource Pane}">
|
||||
|
||||
<!--
|
||||
What the terminal's rectangle holds when Terminal is pressed with nothing open: a prompt, because asking
|
||||
is what a terminal with nothing in it does. It used to be an empty frame, with the tab row's "+" the only
|
||||
thing on it that did anything.
|
||||
|
||||
Two lines, in the order ssh itself asks. The first takes a saved host's name — matched exactly, with the
|
||||
names that match so far printed under it for Tab or a click to take — or user@host[:port] for a machine
|
||||
that is not saved. The second appears only when a password is needed, worded as ssh words it, and is
|
||||
never offered for a host whose keychain binding supplies its own credential.
|
||||
|
||||
Drawn by Avalonia in the WebView's own rectangle, which the occlusion rule allows only because the two
|
||||
are exclusive: this shows while there are no tabs, and the WebView only for a selected tab with a
|
||||
session. See MainWindowViewModel.IsConnectPromptShowing.
|
||||
|
||||
Everything it does goes through the vault's own connect paths — ConnectToHostAsync for a saved host,
|
||||
ConnectToEndpointAsync for an address — so every refusal those make is made here too, and nothing typed
|
||||
at this prompt is written to the keychain.
|
||||
-->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="18,16" Spacing="3" TextElement.FontFamily="{StaticResource MonoFont}">
|
||||
|
||||
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
|
||||
Text="No session open. Connect to a saved host by name, or to any machine as user@host[:port]." />
|
||||
|
||||
<!-- ============ what to connect to ============ -->
|
||||
<Grid ColumnDefinitions="Auto,*" Margin="0,14,0,0">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
|
||||
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
The saved hosts that match so far, best first, the first one Tab's. A wrapped row rather than a list,
|
||||
because this is a shell's completion line and not a picker: a name and an address, nothing to scroll.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding PromptMatches}" Margin="0,2,0,0"
|
||||
IsVisible="{Binding ShowsPromptMatches}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Button Classes="flat" Focusable="False" Padding="6,2" Margin="0,0,6,0" CornerRadius="5"
|
||||
Cursor="Hand" ToolTip.Tip="{Binding Address}"
|
||||
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding Label}" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- ============ the password, when one is needed ============ -->
|
||||
<Grid x:Name="PasswordLine" ColumnDefinitions="Auto,*" IsVisible="{Binding IsPromptAskingForPassword}">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" Foreground="{StaticResource Text}"
|
||||
Text="{Binding PromptPasswordLabel}" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Password" Classes="prompt" Margin="8,0,0,0" PasswordChar="•"
|
||||
Text="{Binding PromptPassword}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="mono" FontSize="12.5" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||
Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
|
||||
|
||||
<TextBlock Classes="mono" FontSize="10.5" Margin="0,10,0,0" Foreground="{StaticResource TextGhost}"
|
||||
Text="ENTER to connect · TAB to complete · ESC to go back · CTRL+K to search hosts" />
|
||||
|
||||
<!--
|
||||
============ where you have been ============
|
||||
|
||||
The machines most recently connected to, newest first — the vault's own connection log, deduplicated
|
||||
by address, so it survives a restart and arrives on a new machine with the keychain. A click puts the
|
||||
row on the prompt line and carries on from there exactly as if it had been typed: a saved host
|
||||
connects, or asks for its password; an address asks for its password.
|
||||
-->
|
||||
<StackPanel Margin="0,22,0,0" Spacing="1" IsVisible="{Binding HasRecentConnections}">
|
||||
<TextBlock Classes="mono" FontSize="11" Foreground="{StaticResource TextFaint}" Text="recent"
|
||||
Margin="0,0,0,4" />
|
||||
<ItemsControl ItemsSource="{Binding RecentConnections}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<Button Classes="flat" Focusable="False" Padding="6,3" CornerRadius="5" Cursor="Hand"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptRecentCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding Address}" />
|
||||
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding HostLabel}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,125 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The empty terminal's prompt: what to connect to, and its password when one is needed.</summary>
|
||||
/// <remarks>
|
||||
/// The keys and the caret live here, as the palette's do in <see cref="QuickConnect"/> and for its reason: a
|
||||
/// <see cref="UserControl"/> hosts in a bare window under the headless harness, and the window itself cannot
|
||||
/// be shown there at all.
|
||||
/// </remarks>
|
||||
internal sealed partial class ConnectPrompt : UserControl
|
||||
{
|
||||
public ConnectPrompt()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Tunnelled, for the reason QuickConnect gives: the prompt owns Enter, Escape and Tab while the caret is
|
||||
// in it, and a text box on the route must not get to them first.
|
||||
AddHandler(KeyDownEvent, OnPromptKey, RoutingStrategies.Tunnel);
|
||||
|
||||
// The password line appearing, or going, moves the caret with it: Enter on a name that needs a password
|
||||
// should leave somebody typing the password, and Escape off it should leave them editing the name.
|
||||
PasswordLine.PropertyChanged += OnLineChanged;
|
||||
}
|
||||
|
||||
/// <summary>The first line's box, for the caret and for the tests.</summary>
|
||||
internal TextBox TargetBox => Target;
|
||||
|
||||
/// <summary>The password line's box, for the caret and for the tests.</summary>
|
||||
internal TextBox PasswordBox => Password;
|
||||
|
||||
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
// Posted, for the reason QuickConnect.TakeKeyboard gives at length: at the instant this becomes
|
||||
// visible its boxes are not in the visual tree yet, and focus is refused to anything that is not.
|
||||
if (change.Property == IsVisibleProperty && change.GetNewValue<bool>())
|
||||
{
|
||||
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLineChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.Property == IsVisibleProperty && IsVisible)
|
||||
{
|
||||
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Puts the caret on whichever line is asking.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves, for the reason <see cref="NativeKeyboardFocus"/> gives: the terminal's WebView can still
|
||||
/// hold the native window's focus after it has been collapsed, and an Avalonia focus without the native
|
||||
/// call is a caret that receives nothing.
|
||||
/// </remarks>
|
||||
private void TakeKeyboard()
|
||||
{
|
||||
if (!IsEffectivelyVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TopLevel.GetTopLevel(this) is Window window)
|
||||
{
|
||||
NativeKeyboardFocus.ReturnTo(window);
|
||||
}
|
||||
|
||||
if (PasswordLine.IsVisible)
|
||||
{
|
||||
Password.Focus();
|
||||
}
|
||||
else
|
||||
{
|
||||
Target.Focus();
|
||||
Target.CaretIndex = Target.Text?.Length ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPromptKey(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (Shell is not { IsConnectPromptShowing: true } shell || e.KeyModifiers != KeyModifiers.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Enter:
|
||||
// Fire-and-forget, as the palette's is: the command reports its own refusals on the prompt,
|
||||
// and a connection's progress and failure in the tab it opens.
|
||||
_ = shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Escape:
|
||||
shell.CancelPromptCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Tab:
|
||||
// Only when there is something to complete to; otherwise Tab moves the focus, as it always does.
|
||||
e.Handled = shell.CompletePrompt();
|
||||
|
||||
if (e.Handled)
|
||||
{
|
||||
Target.CaretIndex = Target.Text?.Length ?? 0;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -707,8 +707,8 @@
|
||||
◆ 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.
|
||||
ticked. No credential is chosen here: the host is reached as whoever this device signed in to
|
||||
that Spelunk as, and the card says who, or offers to sign in. See VaultViewModel.Spelunk.cs.
|
||||
-->
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||
@@ -724,25 +724,20 @@
|
||||
<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.
|
||||
Whether this device can reach that Spelunk, and as whom; or a button to sign in, which
|
||||
opens the browser and waits there. Nothing about it is saved with the host.
|
||||
-->
|
||||
<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>
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="{Binding EditorSpelunkSignInNote}"
|
||||
IsVisible="{Binding EditorSpelunkSignInNote, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding EditorSpelunkNeedsSignIn}">
|
||||
<Button Classes="accent" Height="34" Content="SIGN IN TO SPELUNK"
|
||||
Command="{Binding SignInToEditorSpelunkCommand}"
|
||||
IsVisible="{Binding !IsSigningInToSpelunk}" />
|
||||
<Button Classes="ghost" Height="34" Content="CANCEL"
|
||||
Command="{Binding SignInToEditorSpelunkCancelCommand}"
|
||||
IsVisible="{Binding IsSigningInToSpelunk}" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
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
|
||||
@@ -754,12 +749,12 @@
|
||||
</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.
|
||||
What the route asks for and what it costs, said where the decision is made: this device
|
||||
signed in to that Spelunk, DodoSSH online to vouch for it, 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." />
|
||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. You connect as yourself in Spelunk, which takes the Admin role on the server, and DodoSSH has to be online to check your account first. 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>
|
||||
|
||||
|
||||
@@ -285,9 +285,8 @@
|
||||
<!--
|
||||
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
|
||||
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
|
||||
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
|
||||
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
||||
answers for itself in the tab row's own "+" and in the connecting card below.
|
||||
used to print ("no terminals open · press + or Ctrl+K…") went with it, and the surface with
|
||||
no tab open is answered by the prompt below instead — see ConnectPrompt.axaml.
|
||||
-->
|
||||
<!--
|
||||
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
|
||||
@@ -309,6 +308,16 @@
|
||||
<views:ConnectingCard x:Name="ConnectingPane"
|
||||
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
||||
|
||||
<!--
|
||||
The third: nothing open at all, which is what pressing Terminal on the rail with no
|
||||
session reaches. A prompt asking what to connect to, where an empty frame used to be.
|
||||
Exclusive with the WebView for the same reason the card above is — no tab, no session —
|
||||
and FallbackValue=False for the reason the WebView's own binding gives. See
|
||||
ConnectPrompt.axaml.
|
||||
-->
|
||||
<views:ConnectPrompt x:Name="PromptPane"
|
||||
IsVisible="{Binding IsConnectPromptShowing, FallbackValue=False}" />
|
||||
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
|
||||
browser process tree, so twenty tabs would cost twenty of them.
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
Ctrl+K: type a few characters of a host's name and press Enter to connect to it.
|
||||
Ctrl+K: type a few characters of a host's name and press Enter to connect to it — or type user@host, press
|
||||
Enter, and give a password, to connect to a machine that is not saved at all.
|
||||
|
||||
The design's box promises "search hosts · run command". Only the first half is here, because there is no
|
||||
snippet or saved-command item type in the vault for the second half to run — see
|
||||
@@ -35,93 +36,171 @@
|
||||
BorderThickness="1" CornerRadius="14">
|
||||
<StackPanel>
|
||||
|
||||
<!--
|
||||
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
|
||||
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
|
||||
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
|
||||
it already has a real one.
|
||||
-->
|
||||
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text=">_" FontSize="14" FontWeight="Bold"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
|
||||
PlaceholderText="search hosts" Margin="10,0,0,0"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||
Background="Transparent" BorderThickness="0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel x:Name="QueryStep" IsVisible="{Binding IsAskingForSearchQuery}">
|
||||
|
||||
<!--
|
||||
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
|
||||
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
|
||||
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
|
||||
it already has a real one.
|
||||
-->
|
||||
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text=">_" FontSize="14" FontWeight="Bold"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
|
||||
PlaceholderText="search hosts, or type user@host" Margin="10,0,0,0"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||
Background="Transparent" BorderThickness="0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The typed address, above the saved hosts: what was typed was an address, so the row that dials it
|
||||
is the one the query is most plainly about. It is lit by IsAdHocSelected rather than by the list's
|
||||
own selection, because it is not a host and the list below holds hosts only — see the remark on
|
||||
MainWindowViewModel.IsAdHocSelected. Classes="qcrow adhoc" shares the list rows' look; the
|
||||
selected state is a class here because there is no ListBoxItem around it to carry :selected.
|
||||
|
||||
"not saved" in the corner, where a saved row says how it authenticates: that is this row's answer
|
||||
to the same question, and the one thing worth knowing before choosing it.
|
||||
-->
|
||||
<Border x:Name="AdHocRow" Classes="qcrow adhoc" Classes.selected="{Binding IsAdHocSelected}"
|
||||
Height="44" CornerRadius="9" Padding="10,0" Margin="8,6,8,2"
|
||||
IsVisible="{Binding HasAdHocTarget}" Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono qcrow-text" Text=">_" FontSize="12"
|
||||
FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-text" Text="{Binding AdHocAddress}" FontSize="13"
|
||||
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="connect with a password" FontSize="11.5" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="not saved" FontSize="11"
|
||||
Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Why a query that looks like an address has no row to dial it — a missing port number, say. -->
|
||||
<TextBlock Classes="hint" FontSize="12" Margin="18,12,18,4"
|
||||
Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding AdHocRefusal}"
|
||||
IsVisible="{Binding HasAdHocRefusal}" />
|
||||
|
||||
<!--
|
||||
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
|
||||
took focus would take the arrow keys away from the box being typed into.
|
||||
|
||||
A click connects rather than merely selecting, which is what a list of hosts under a search box
|
||||
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
|
||||
act, and a pointer that only highlighted would leave the palette open over a choice already made.
|
||||
The gesture is wired in the code-behind, as the sidebar's double-click is.
|
||||
|
||||
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
|
||||
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
|
||||
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
|
||||
shows its corners.
|
||||
-->
|
||||
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
|
||||
ItemsSource="{Binding SearchResults}"
|
||||
SelectedItem="{Binding SelectedSearchResult}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<!--
|
||||
Green when this host has a terminal open right now, grey otherwise — the same two-state
|
||||
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
|
||||
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
|
||||
-->
|
||||
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
Width="8" Height="8" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
|
||||
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Two readings stacked against the two on the left, and in the same order: what this row
|
||||
IS above, how it is reached below.
|
||||
|
||||
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
|
||||
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
|
||||
The palette searches every readable vault at once, so without it two hosts a team and a
|
||||
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
|
||||
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
|
||||
vault name would otherwise take its width out of the host name beside it.
|
||||
|
||||
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
||||
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
||||
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
|
||||
HorizontalAlignment="Right" MaxWidth="150"
|
||||
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!--
|
||||
Names the other thing the box takes, at the moment somebody is most likely to want it: a name that
|
||||
matches nothing is often a machine that was never saved.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
|
||||
Text="No host matches that. Type user@host to connect without saving it."
|
||||
IsVisible="{Binding HasNothingToOffer}" />
|
||||
|
||||
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
||||
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
|
||||
took focus would take the arrow keys away from the box being typed into.
|
||||
============ the typed address's password ============
|
||||
|
||||
A click connects rather than merely selecting, which is what a list of hosts under a search box
|
||||
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
|
||||
act, and a pointer that only highlighted would leave the palette open over a choice already made.
|
||||
The gesture is wired in the code-behind, as the sidebar's double-click is.
|
||||
|
||||
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
|
||||
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
|
||||
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
|
||||
shows its corners.
|
||||
The second step, in the same card, so the keyboard never leaves the palette: Enter on the address
|
||||
comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries
|
||||
PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword,
|
||||
and VaultViewModel.ConnectToEndpointAsync for why a typed password is the only way in.
|
||||
-->
|
||||
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
|
||||
ItemsSource="{Binding SearchResults}"
|
||||
SelectedItem="{Binding SelectedSearchResult}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<!--
|
||||
Green when this host has a terminal open right now, grey otherwise — the same two-state
|
||||
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
|
||||
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
|
||||
-->
|
||||
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
Width="8" Height="8" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
|
||||
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Two readings stacked against the two on the left, and in the same order: what this row
|
||||
IS above, how it is reached below.
|
||||
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
|
||||
|
||||
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
|
||||
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
|
||||
The palette searches every readable vault at once, so without it two hosts a team and a
|
||||
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
|
||||
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
|
||||
vault name would otherwise take its width out of the host name beside it.
|
||||
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text=">_" FontSize="14" FontWeight="Bold"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding AdHocAddress}" Margin="10,0,0,0"
|
||||
FontSize="14" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
||||
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
||||
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
|
||||
HorizontalAlignment="Right" MaxWidth="150"
|
||||
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<StackPanel Margin="16,14" Spacing="8">
|
||||
<TextBox x:Name="Password" Text="{Binding AdHocPassword}" PasswordChar="•"
|
||||
PlaceholderText="password" FontSize="14" Height="38" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
|
||||
Text="No host matches that."
|
||||
IsVisible="{Binding !HasSearchResults}" />
|
||||
<TextBlock Classes="hint" FontSize="12" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding AdHocRefusal}" IsVisible="{Binding HasAdHocRefusal}" />
|
||||
|
||||
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
||||
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
|
||||
</Border>
|
||||
<TextBlock Classes="hint" FontSize="12"
|
||||
Text="Nothing here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key." />
|
||||
</StackPanel>
|
||||
|
||||
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
||||
Text="ENTER to connect · ESC to go back" />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -39,11 +39,25 @@ internal sealed partial class QuickConnect : UserControl
|
||||
// drag across the list — and the ListBox has moved its own selection by the time a tap completes,
|
||||
// which is what makes the handler below a matter of reading the selection rather than hit-testing.
|
||||
Results.Tapped += OnResultTapped;
|
||||
AdHocRow.Tapped += OnAdHocRowTapped;
|
||||
|
||||
// The two steps swap in place, and whichever one has just appeared takes the caret: typing an address
|
||||
// and pressing Enter should leave somebody typing a password, not looking for the box to click into.
|
||||
// Watched on the steps themselves rather than on the view model, for the reason TakeKeyboard gives —
|
||||
// the view model's change arrives ahead of the binding that reveals the step.
|
||||
QueryStep.PropertyChanged += OnStepChanged;
|
||||
PasswordStep.PropertyChanged += OnStepChanged;
|
||||
}
|
||||
|
||||
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
|
||||
internal TextBox QueryBox => Query;
|
||||
|
||||
/// <summary>The typed address's password box, which takes the caret when the palette asks for it.</summary>
|
||||
internal TextBox PasswordBox => Password;
|
||||
|
||||
/// <summary>The row offering to dial what was typed, when it reads as an address.</summary>
|
||||
internal Control AdHocTargetRow => AdHocRow;
|
||||
|
||||
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
||||
|
||||
/// <summary>
|
||||
@@ -63,6 +77,29 @@ internal sealed partial class QuickConnect : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
// The password step answers only Enter and Escape, and Escape there goes back to the address: a
|
||||
// mistyped address is the likeliest reason to want out of it, and closing would throw that away too.
|
||||
if (shell.IsAskingForAdHocPassword)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape:
|
||||
shell.BackToSearchQueryCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Enter:
|
||||
_ = shell.ConnectToAdHocTargetCommand.ExecuteAsync(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape:
|
||||
@@ -76,12 +113,12 @@ internal sealed partial class QuickConnect : UserControl
|
||||
break;
|
||||
|
||||
case Key.Down:
|
||||
Move(shell, 1);
|
||||
shell.MoveSearchSelection(1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Up:
|
||||
Move(shell, -1);
|
||||
shell.MoveSearchSelection(-1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
@@ -129,23 +166,38 @@ internal sealed partial class QuickConnect : UserControl
|
||||
NativeKeyboardFocus.ReturnTo(window);
|
||||
}
|
||||
|
||||
Query.Focus();
|
||||
if (PasswordStep.IsVisible)
|
||||
{
|
||||
Password.Focus();
|
||||
}
|
||||
else
|
||||
{
|
||||
Query.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
|
||||
private static void Move(MainWindowViewModel shell, int delta)
|
||||
/// <summary>Hands the caret to whichever step has just been revealed.</summary>
|
||||
/// <remarks>Posted, for the same reason the palette's own opening is: see <see cref="TakeKeyboard"/>.</remarks>
|
||||
private void OnStepChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (shell.SearchResults.Count == 0)
|
||||
if (e.Property == IsVisibleProperty && e.GetNewValue<bool>() && IsVisible)
|
||||
{
|
||||
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the typed address, for a click on its row: the pointer's version of Enter on it.
|
||||
/// </summary>
|
||||
private void OnAdHocRowTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (Shell is not { IsSearching: true, HasAdHocTarget: true } shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = shell.SelectedSearchResult is { } selected
|
||||
? shell.SearchResults.IndexOf(selected)
|
||||
: -1;
|
||||
|
||||
shell.SelectedSearchResult =
|
||||
shell.SearchResults[Math.Clamp(current + delta, 0, shell.SearchResults.Count - 1)];
|
||||
e.Handled = true;
|
||||
shell.ChooseAdHocTargetCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -84,7 +84,10 @@
|
||||
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
||||
IsVisible="{Binding Updates.IsUnsupported}" />
|
||||
IsVisible="{Binding Updates.IsUnsupportedForTheUsualReason}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="{Binding Updates.UnsupportedReason}"
|
||||
IsVisible="{Binding Updates.UnsupportedReason, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -87,6 +87,11 @@
|
||||
<TextBlock Classes="settingsrowtitle" Text="Synchronise" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open." />
|
||||
<!-- What SYNC NOW said. The status bar that carries it elsewhere is hidden in settings
|
||||
mode, so without this the button ran, failed or found the machine offline in silence. -->
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
IsVisible="{Binding Vault.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}"
|
||||
Text="{Binding Vault.Status}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="ghost" Height="38" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||
|
||||
@@ -26,13 +26,12 @@
|
||||
on purpose — known-hosts is a MAIN-chrome screen, and there is no honest way to show it without leaving;
|
||||
ShowScreenCommand already does that for any target that is not Preferences or Vaults.
|
||||
|
||||
SPELUNK is where a Spelunk API key is filled in: an address and a key, saved as a Spelunk key item in the
|
||||
personal vault (VaultViewModel.SaveSpelunkKeyCommand) and synced like every secret. The address is the
|
||||
point — a key saved here is only ever sent there, and links from that Spelunk connect straight away
|
||||
because saving it is the user saying so. A second key for an address already saved replaces the first.
|
||||
The key box is a password box and is emptied the moment the key is in the keychain; the list below it
|
||||
shows the name, the address and how many hosts use each key, never the key. Deleting goes through the
|
||||
same ConfirmDeleteCard every other deletion does, with a count of the hosts that would stop connecting.
|
||||
SPELUNK is where this device signs in to a Spelunk: an address and SIGN IN, which opens the browser at
|
||||
Spelunk and waits for it (VaultViewModel.SignInToSpelunkCommand, cancellable while it waits). What comes
|
||||
back stays on this device — it is not a keychain item and does not sync — and links from that Spelunk
|
||||
connect straight away because signing in is the user saying so. Signing in again to an address replaces
|
||||
the sign-in. The list below shows who each sign-in is and how many hosts reach Spelunk through it, never
|
||||
its token, and SIGN OUT ends it in Spelunk and here.
|
||||
|
||||
RECENT SECURITY EVENTS is the link row alone, not the design's own list of rows. A real list would mean
|
||||
building a filtered read over LogsViewModel's activity and connection logs — which entries count as
|
||||
@@ -115,76 +114,66 @@
|
||||
|
||||
<TextBlock Classes="settingssection" Text="SPELUNK" />
|
||||
|
||||
<!--
|
||||
Signing this device in to a Spelunk. Spelunk asks in the browser, however its person signs in there,
|
||||
and what it gives back stays on this device: each device signs in on its own, and signing out of
|
||||
DodoSSH signs out of every Spelunk too. See VaultViewModel.SpelunkSignIns.cs.
|
||||
-->
|
||||
<Border Classes="settingscard">
|
||||
<StackPanel>
|
||||
<Border Classes="settingsrow">
|
||||
<StackPanel Spacing="10">
|
||||
<StackPanel Spacing="6" MaxWidth="640" HorizontalAlignment="Left">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Spelunk API key" />
|
||||
<TextBlock Classes="settingsrowtitle" Text="Sign in to Spelunk" />
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
Text="Hosts reached through Spelunk use this key, and links from that Spelunk connect straight away. It is kept encrypted in your personal vault, syncs to your other devices, and is only ever sent to the address below. Use a key with the Admin role." />
|
||||
Text="Hosts reached through Spelunk connect as you, in every organisation you are in there, and links from that Spelunk connect straight away. Spelunk asks in your browser. The sign-in stays on this device and ends when you sign out here, in Spelunk, or of DodoSSH." />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,*,Auto" ColumnSpacing="10"
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10"
|
||||
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkKeyFormApi}"
|
||||
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||
<TextBox Grid.Column="1" Height="38" PasswordChar="•" Text="{Binding Vault.SpelunkKeyFormKey}"
|
||||
PlaceholderText="API key (spelunk_…)">
|
||||
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkSignInFormApi}"
|
||||
PlaceholderText="Spelunk API address (https://api.…)">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.SaveSpelunkKeyCommand}" />
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.SignInToSpelunkCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="2" Classes="accent" Height="38" Content="SAVE KEY"
|
||||
Command="{Binding Vault.SaveSpelunkKeyCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy, FallbackValue=False}" />
|
||||
<Button Grid.Column="1" Classes="accent" Height="38" Content="SIGN IN"
|
||||
Command="{Binding Vault.SignInToSpelunkCommand}"
|
||||
IsVisible="{Binding !Vault.IsSigningInToSpelunk, FallbackValue=True}" />
|
||||
<Button Grid.Column="1" Classes="ghost" Height="38" Content="CANCEL"
|
||||
Command="{Binding Vault.SignInToSpelunkCancelCommand}"
|
||||
IsVisible="{Binding Vault.IsSigningInToSpelunk, FallbackValue=False}" />
|
||||
</Grid>
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
Text="{Binding Vault.SpelunkKeyFormNote}"
|
||||
IsVisible="{Binding Vault.HasSpelunkKeyFormNote, FallbackValue=False}" />
|
||||
Text="{Binding Vault.SpelunkSignInFormNote}"
|
||||
IsVisible="{Binding Vault.HasSpelunkSignInFormNote, FallbackValue=False}" />
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
|
||||
Text="Unlock your keychain to save a Spelunk key." />
|
||||
Text="Unlock your keychain to sign in to Spelunk." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- The delete confirmation, in place, as the tags and groups pages draw it. -->
|
||||
<Border Margin="24,12" Padding="20" CornerRadius="12"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="1"
|
||||
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}">
|
||||
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
|
||||
</Border>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Vault.SpelunkKeys}">
|
||||
<ItemsControl ItemsSource="{Binding Vault.SpelunkSignIns}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SpelunkKeyRowViewModel">
|
||||
<DataTemplate x:DataType="vm:SpelunkSignInRowViewModel">
|
||||
<Border Classes="settingsrow" Padding="24,14">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="12">
|
||||
<StackPanel Grid.Column="0" Spacing="4" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="settingsrowtitle" Text="{Binding Label}" />
|
||||
<Border Classes="chip" IsVisible="{Binding HasVaultBadge}" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding VaultBadge}" />
|
||||
</Border>
|
||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding Badge}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="settingsrowtitle" Text="{Binding Who}" />
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding ApiBase}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="settingsrowcaption" VerticalAlignment="Center"
|
||||
Text="{Binding UsedBy}" />
|
||||
<Button Grid.Column="2" Classes="paneicon danger" Width="30" Height="30" FontSize="13"
|
||||
<Button Grid.Column="2" Classes="ghost" Height="30" Content="SIGN OUT"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteSpelunkKeyRowCommand}"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.SignOutOfSpelunkCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="Delete this key. Hosts that reach Spelunk with it stop connecting until they name another.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
ToolTip.Tip="Sign this device out of this Spelunk. Hosts reached through it stop connecting until you sign in again." />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -3,7 +3,7 @@ using Avalonia.Controls;
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and Spelunk API keys.
|
||||
/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and signing in to Spelunk.
|
||||
/// </summary>
|
||||
internal sealed partial class SettingsSecurityPage : UserControl
|
||||
{
|
||||
|
||||
@@ -70,6 +70,13 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- What SYNC NOW said. The status bar that carries it elsewhere is hidden in settings mode, so
|
||||
without this the button ran, failed or found the machine offline in perfect silence. -->
|
||||
<TextBlock Margin="0,10,0,0" FontSize="11.5" TextWrapping="Wrap" HorizontalAlignment="Right"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding Vault.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}"
|
||||
Text="{Binding Vault.Status}" />
|
||||
|
||||
<!-- The name-a-vault and rename-a-vault forms, in place — this window has no modal idiom for a bare
|
||||
text field, and the members panel below is a different kind of overlay: over one vault's people,
|
||||
not over a form. -->
|
||||
|
||||
@@ -209,10 +209,9 @@
|
||||
design's, unlike HOST/BUCKET across the arrow column, which keeps its own words for a reason
|
||||
recorded where it is drawn.
|
||||
|
||||
UP is arrow_upward here and arrow_downward on the host/bucket pane below, matching the design's own
|
||||
two glyphs exactly rather than picking one "up a directory" icon for both — both buttons do the same
|
||||
thing (go to the parent directory), and the pair reading differently is the design's own choice
|
||||
faithfully carried over rather than a functional difference invented to justify it.
|
||||
UP is arrow_upward on both panes. The design drew arrow_downward on the host/bucket pane, and that
|
||||
was carried over until it was reported as a wrong icon — which it is: both buttons go to the parent
|
||||
directory, and a down arrow under the tooltip "Up one directory" says the opposite of what it does.
|
||||
-->
|
||||
<!--
|
||||
◆ THE PATH SITS IN THE STAR COLUMN, ALONE, AND THAT PLACEMENT IS LOAD-BEARING.
|
||||
@@ -398,8 +397,8 @@
|
||||
directory tree and a flat namespace with inferred folders — and that reason still holds, so only
|
||||
the style moves to the design's 10px tracked label, not the wording.
|
||||
|
||||
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
|
||||
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
||||
UP is arrow_upward, the same as the local pane's; see that pane's own remark for why it is no longer
|
||||
the design's arrow_downward.
|
||||
-->
|
||||
<!--
|
||||
◆ THE PATH IS TYPED HERE, and that is what the star column is now holding.
|
||||
@@ -446,7 +445,7 @@
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
||||
@@ -546,7 +545,7 @@
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
|
||||
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding MoveRemoteCommand}"
|
||||
IsEnabled="{Binding CanMoveRemote}" ToolTip.Tip="Move to another directory">
|
||||
|
||||
@@ -145,18 +145,26 @@ public static class HostSecretCodec
|
||||
/// shows the host, refuses to edit it, and says to update.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only a route that signs in is written here. Every other Spelunk host stays at
|
||||
/// <see cref="SpelunkSchemaVersion"/>, byte for byte what it was.
|
||||
/// No longer written: every Spelunk host is written at <see cref="SpelunkSignedInDeviceSchemaVersion"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const int SpelunkSignInSchemaVersion = 10;
|
||||
|
||||
/// <summary>The version from which a <see cref="SpelunkRoute"/> names no API key.</summary>
|
||||
/// <remarks>
|
||||
/// Every Spelunk host is written here: the device's own sign-in to Spelunk replaced the key a route used
|
||||
/// to name. A build that knows 10 requires that key and would read the host as damaged; written at this
|
||||
/// version, it shows the host, refuses to edit it, and says to update. A payload written before still
|
||||
/// decodes — its key id is an unknown member, skipped like any other.
|
||||
/// </remarks>
|
||||
public const int SpelunkSignedInDeviceSchemaVersion = 11;
|
||||
|
||||
/// <summary>The highest schema version this build can write.</summary>
|
||||
/// <remarks>
|
||||
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
|
||||
/// maximum. A new field added below this line has to be named here too.
|
||||
/// </remarks>
|
||||
public const int CurrentSchemaVersion = SpelunkSignInSchemaVersion;
|
||||
public const int CurrentSchemaVersion = SpelunkSignedInDeviceSchemaVersion;
|
||||
|
||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||
@@ -292,12 +300,7 @@ public static class HostSecretCodec
|
||||
|
||||
if (host.Spelunk is not null)
|
||||
{
|
||||
version = Math.Max(version, SpelunkSchemaVersion);
|
||||
}
|
||||
|
||||
if (host.SignsInThroughSpelunk)
|
||||
{
|
||||
version = Math.Max(version, SpelunkSignInSchemaVersion);
|
||||
version = Math.Max(version, SpelunkSignedInDeviceSchemaVersion);
|
||||
}
|
||||
|
||||
return version;
|
||||
@@ -450,7 +453,6 @@ public static class HostSecretCodec
|
||||
{
|
||||
ApiBase = parsed.Api ?? string.Empty,
|
||||
ServerId = parsed.ServerId ?? Guid.Empty,
|
||||
ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty,
|
||||
|
||||
// False folds to the default, as AsksForPassword's does: one state, one spelling.
|
||||
SignInThroughSpelunk = parsed.SignIn is true,
|
||||
@@ -544,19 +546,13 @@ internal sealed class SpelunkPayloadDocument
|
||||
|
||||
public Guid? ServerId { get; set; }
|
||||
|
||||
public Guid? ApiKeyCredentialId { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Last, and null unless true, so a route that does not sign in keeps the bytes it had at version 9.
|
||||
/// See <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>.
|
||||
/// </remarks>
|
||||
/// <remarks>Last, and null unless true. See <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>.</remarks>
|
||||
public bool? SignIn { get; set; }
|
||||
|
||||
public static SpelunkPayloadDocument From(SpelunkRoute route) => new()
|
||||
{
|
||||
Api = route.ApiBase,
|
||||
ServerId = route.ServerId,
|
||||
ApiKeyCredentialId = route.ApiKeyCredentialId,
|
||||
|
||||
// Null rather than false, so a route that does not sign in encodes exactly as it did before the
|
||||
// field existed — the rule AsksForPassword follows.
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A Spelunk API key and the one Spelunk it may be sent to, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The address is what makes this an item of its own.</b> A key kept as a credential's password can be
|
||||
/// sent anywhere a host names, and a link from Spelunk can be sent by anything on the machine — so a link
|
||||
/// that is not already backed by a saved host has to stop and ask. A key stored with its address says, once
|
||||
/// and in the keychain, where the user agreed it may go: a link from that Spelunk connects straight away, and
|
||||
/// a host naming this key with any other address is refused at connect rather than sending it there. See
|
||||
/// <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="ApiKey"/> is a password, and everything this codebase does about passwords applies to
|
||||
/// it</b>, as it does to <see cref="ObjectStoreSecret.SecretAccessKey"/>: it is inside the encrypted payload,
|
||||
/// the activity log records that it changed and never what it was, and the merge reports that it differed
|
||||
/// rather than either value.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SpelunkKeySecret : IVaultSecret
|
||||
{
|
||||
/// <summary>The longest API key stored.</summary>
|
||||
/// <remarks>
|
||||
/// Far beyond any real key, for the reason <see cref="SpelunkRoute.MaxApiBaseLength"/> exists: an
|
||||
/// unbounded string is the shape a corrupted payload takes, and this one goes into a request header.
|
||||
/// </remarks>
|
||||
public const int MaxApiKeyLength = 4096;
|
||||
|
||||
/// <summary>What the user calls this key.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The Spelunk API this key is for, as <see cref="SpelunkRoute.TryNormalizeApiBase"/> writes it, and the
|
||||
/// only address it is ever sent to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Normalised for the reason <see cref="SpelunkRoute.ApiBase"/> is: it is compared, byte for byte, with
|
||||
/// the address a host or a link names.
|
||||
/// </remarks>
|
||||
public required string ApiBase { get; init; }
|
||||
|
||||
/// <summary>The API key itself, as Spelunk issued it.</summary>
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Label))
|
||||
{
|
||||
reason = "A Spelunk key needs a name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ApiBase))
|
||||
{
|
||||
reason = "A Spelunk key needs the address of the Spelunk it is for.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SpelunkRoute.TryNormalizeApiBase(ApiBase, out var normalized, out reason))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(normalized, ApiBase, StringComparison.Ordinal))
|
||||
{
|
||||
reason = "The Spelunk API address is not in its normal form.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ApiKey))
|
||||
{
|
||||
reason = "A Spelunk key needs the key.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// A header value cannot hold a line break, and a key with one would fail at the first connect with an
|
||||
// HTTP error that names neither the key nor this item.
|
||||
if (ApiKey.Length > MaxApiKeyLength || ApiKey.Any(char.IsControl))
|
||||
{
|
||||
reason = "That does not look like a Spelunk API key.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded Spelunk key payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Key">The key.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record SpelunkKeySecretDocument(SpelunkKeySecret Key, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > SpelunkKeySecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a Spelunk key item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="ObjectStoreSecretCodec"/>, for the same reasons and with the same guarantees.
|
||||
/// </remarks>
|
||||
public static class SpelunkKeySecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises a key to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The key is not valid for storage.</exception>
|
||||
public static byte[] Encode(SpelunkKeySecret key)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
if (!key.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(key));
|
||||
}
|
||||
|
||||
var document = new SpelunkKeyPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
Label = key.Label,
|
||||
ApiBase = key.ApiBase,
|
||||
ApiKey = key.ApiKey,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out SpelunkKeySecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
SpelunkKeyPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new SpelunkKeySecret
|
||||
{
|
||||
Label = parsed.Label ?? string.Empty,
|
||||
ApiBase = parsed.ApiBase ?? string.Empty,
|
||||
ApiKey = parsed.ApiKey ?? string.Empty,
|
||||
};
|
||||
|
||||
// A key whose address is not in its normal form is refused rather than normalised here: the address
|
||||
// is what decides where the key may go, and a payload that disagrees with how every client writes it
|
||||
// is not one to guess about.
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new SpelunkKeySecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class SpelunkKeyPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
|
||||
public string? ApiBase { get; set; }
|
||||
|
||||
public string? ApiKey { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(SpelunkKeyPayloadDocument))]
|
||||
internal sealed partial class SpelunkKeyPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -1,89 +0,0 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged Spelunk key, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The key to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record SpelunkKeyMergeResult(
|
||||
SpelunkKeySecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a Spelunk key against the version they both started from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The address and the key merge as one value, not two.</b> The pair is the whole meaning of this item —
|
||||
/// "this key may go to this Spelunk" — and merging them apart could keep one device's new address beside the
|
||||
/// other device's new key, sending a key to a Spelunk nobody ever paired it with. So when both sides changed
|
||||
/// the pair, one side's pair is kept whole.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The key never reaches the conflict log</b>, for the reason a secret access key does not: a discarded
|
||||
/// one is very often still live. The addresses are shown, because which Spelunk the kept key goes to is the
|
||||
/// whole content of the notice.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SpelunkKeySecretMerge
|
||||
{
|
||||
/// <summary>The name a conflict over the address and key is recorded under.</summary>
|
||||
public const string PairField = "ApiBaseAndKey";
|
||||
|
||||
/// <summary>Produces the merged key.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static SpelunkKeyMergeResult Merge(
|
||||
SpelunkKeySecret ancestor,
|
||||
SpelunkKeySecret local,
|
||||
SpelunkKeySecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
var label = ThreeWayMerge.Scalar(ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
|
||||
|
||||
if (label.IsConflicted)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
nameof(SpelunkKeySecret.Label),
|
||||
MergeSide.Local,
|
||||
label.Value,
|
||||
label.Discarded ?? "(none)",
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
// A tuple of two strings compares both ordinally, which is the comparison each field needs.
|
||||
var pair = ThreeWayMerge.Scalar(
|
||||
(ancestor.ApiBase, ancestor.ApiKey),
|
||||
(local.ApiBase, local.ApiKey),
|
||||
(remote.ApiBase, remote.ApiKey));
|
||||
|
||||
if (pair.IsConflicted)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
PairField,
|
||||
MergeSide.Local,
|
||||
$"{pair.Value.ApiBase} (with its key)",
|
||||
string.Equals(pair.Discarded.ApiBase, pair.Value.ApiBase, StringComparison.Ordinal)
|
||||
? "(a different key for the same address was discarded)"
|
||||
: $"{pair.Discarded.ApiBase} (with its key)",
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
var merged = new SpelunkKeySecret
|
||||
{
|
||||
Label = label.Value,
|
||||
ApiBase = pair.Value.ApiBase,
|
||||
ApiKey = pair.Value.ApiKey,
|
||||
};
|
||||
|
||||
return new SpelunkKeyMergeResult(merged, conflicts);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// How a host is reached through Spelunk: which Spelunk, which of its servers, and which key opens the way.
|
||||
/// How a host is reached through Spelunk: which Spelunk, and which of its servers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -15,15 +15,15 @@ namespace DodoSSH.Client.Domain;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Inside the host's encrypted payload, and nowhere else.</b> Unlike the relay, nothing about this has to
|
||||
/// be readable by the DodoSSH server: the tunnel is opened by this client, against Spelunk, with a key only
|
||||
/// the vault holds. So the concession ADR 0004 argues for the relay's address is not made for this.
|
||||
/// be readable by the DodoSSH server: the tunnel is opened by this client, against Spelunk, as whoever is
|
||||
/// signed in to that Spelunk on this device. So the concession ADR 0004 argues for the relay's address is
|
||||
/// not made for this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The API key is referenced, not embedded.</b> <see cref="ApiKeyCredentialId"/> names the keychain item
|
||||
/// holding it, for the reason <see cref="HostSecret.CredentialId"/> gives: one Spelunk key is the key for
|
||||
/// every server in its organisation, and a copy per host is twenty places to rotate it and one to forget.
|
||||
/// That item is a <see cref="SpelunkKeySecret"/>, which also says the one address the key may go to, or —
|
||||
/// for a host set up before those existed — an ordinary credential whose password is the key.
|
||||
/// <b>No credential travels with the host.</b> Each device signs in to a Spelunk once, through the browser,
|
||||
/// and keeps what that gives it to itself (see <c>SpelunkSignIns</c>): the same host opens on every device
|
||||
/// whose person is signed in to its Spelunk, as that person, in whichever of their organisations the server
|
||||
/// is in. Nothing about who may connect is synced.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SpelunkRoute
|
||||
@@ -50,32 +50,13 @@ public sealed record SpelunkRoute
|
||||
/// <summary>The server's id in Spelunk.</summary>
|
||||
public required Guid ServerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The keychain item holding the Spelunk API key: a <see cref="SpelunkKeySecret"/>, or a credential whose
|
||||
/// password is the key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Named for the credential it always was, and kept so on the wire: item ids are unique across kinds, so
|
||||
/// the one id says which, and a build that predates Spelunk keys reads a host naming one as a host whose
|
||||
/// key credential has gone — refused at connect, and kept as it is by an edit — rather than as damage.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Required rather than nullable. A route with no key can never be opened, and a host saved that way
|
||||
/// would refuse every connection with a sentence about something the editor let through. The reference
|
||||
/// can still dangle — the credential may be deleted on another machine — and that is refused where it
|
||||
/// is noticed, at connect, exactly as a missing SSH credential is.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public required Guid ApiKeyCredentialId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Spelunk signs the user in, so the host needs no SSH key, credential or password of its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For each connection this client makes a throwaway key pair in memory and sends the public half with
|
||||
/// the ticket request, which the API key authorises. The agent then runs a one-connection SSH server on
|
||||
/// the ticket request, which this device's sign-in to Spelunk authorises. The agent then runs a one-connection SSH server on
|
||||
/// the machine whose only authorised key is that one, and says which account it logs in as. It is real
|
||||
/// SSH to the server's own host key, so shells, file transfer and port forwards work as for any host,
|
||||
/// and the key is gone when the connection is.
|
||||
@@ -121,12 +102,6 @@ public sealed record SpelunkRoute
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ApiKeyCredentialId == Guid.Empty)
|
||||
{
|
||||
reason = "A host reached through Spelunk needs its Spelunk API key chosen.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
@@ -136,8 +111,8 @@ public sealed record SpelunkRoute
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>HTTPS, or plain HTTP to this machine only.</b> The API key travels in a header on every tunnel, so
|
||||
/// an address that sends it in clear across a network is refused rather than stored. A Spelunk running
|
||||
/// <b>HTTPS, or plain HTTP to this machine only.</b> The sign-in's token travels in a header on every
|
||||
/// tunnel, so an address that sends it in clear across a network is refused rather than stored. A Spelunk running
|
||||
/// on the developer's own box is the one place plain HTTP is harmless, and it is allowed there so the
|
||||
/// feature can be tried against one.
|
||||
/// </para>
|
||||
@@ -175,7 +150,7 @@ public sealed record SpelunkRoute
|
||||
|
||||
if (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && !uri.IsLoopback)
|
||||
{
|
||||
reason = "The Spelunk API address must use https, because the API key is sent with every connection.";
|
||||
reason = "The Spelunk API address must use https, because your sign-in is sent with every connection.";
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,9 +141,32 @@ public interface IUpdateChannel
|
||||
/// <see cref="UnavailableDeviceKeyStore"/>: the caller then has one shape to write against, and "no
|
||||
/// updater here" is a thing the interface can express rather than a case every call site has to remember.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One situation outside those four carries a <see cref="Reason"/>.</b> A Linux copy sitting exactly
|
||||
/// where the RPM puts it that no RPM owns — built locally and installed by another distribution's package
|
||||
/// manager. It looks installed, so the sentence about checkouts and unzipped copies would describe the
|
||||
/// wrong machine, and the person reading it needs telling which updater is theirs.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class UnavailableUpdateChannel : IUpdateChannel
|
||||
{
|
||||
/// <summary>Creates one for the situations that need no telling apart.</summary>
|
||||
public UnavailableUpdateChannel()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates one whose situation is worth a sentence of its own.</summary>
|
||||
/// <param name="reason">What to tell the user, as a sentence they can act on.</param>
|
||||
public UnavailableUpdateChannel(string reason)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
||||
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
/// <summary>Why this copy cannot replace itself, where that is not the usual why.</summary>
|
||||
public string? Reason { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSupported => false;
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ internal sealed class RefreshingAccessTokenProvider(
|
||||
{
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private TokenSet tokens = initial;
|
||||
private DateTimeOffset lastRefreshedAt = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// The refresh token this provider currently holds, or null when none was granted.
|
||||
@@ -82,6 +83,7 @@ internal sealed class RefreshingAccessTokenProvider(
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Volatile.Write(ref tokens, refreshed);
|
||||
lastRefreshedAt = clock.GetUtcNow();
|
||||
|
||||
return refreshed.AccessToken;
|
||||
}
|
||||
@@ -91,6 +93,44 @@ internal sealed class RefreshingAccessTokenProvider(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes now, whatever the expiry says, unless that was done within <paramref name="within"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The refresh grant is the one question the identity provider answers about whether a sign-in still
|
||||
/// stands: a disabled account, a revoked session or a removed user gets <c>invalid_grant</c> here, where
|
||||
/// an access token already issued would go on being accepted until it expired. Under the same lock as the
|
||||
/// ordinary refresh, so the two cannot spend one rotating refresh token twice.
|
||||
/// </remarks>
|
||||
/// <exception cref="OidcException">The provider refused, or could not be asked.</exception>
|
||||
internal async Task RefreshNowAsync(TimeSpan within, CancellationToken cancellationToken)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
if (now - lastRefreshedAt < within)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = Volatile.Read(ref tokens);
|
||||
if (current.RefreshToken is null)
|
||||
{
|
||||
throw new OidcException("This sign-in has no refresh token, so it cannot be checked. Sign in again.", "invalid_grant");
|
||||
}
|
||||
|
||||
var refreshed = await oidc.RefreshAsync(current.RefreshToken, cancellationToken).ConfigureAwait(false);
|
||||
Volatile.Write(ref tokens, refreshed);
|
||||
lastRefreshedAt = now;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => gate.Dispose();
|
||||
}
|
||||
|
||||
@@ -161,6 +201,19 @@ public interface IVaultServer : IDisposable
|
||||
/// </remarks>
|
||||
IVaultEventStream Events { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Asks the identity provider, now, whether this sign-in still stands — at most once a minute.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For what must not go on working for a person the account has lost: connecting through Spelunk asks
|
||||
/// before every tunnel, so a removed or disabled account stops it within a minute rather than when an
|
||||
/// access token happens to expire. A stand-in server answers yes, which is why this has a body.
|
||||
/// </remarks>
|
||||
/// <exception cref="DodoSSH.Client.Auth.OidcException">
|
||||
/// The provider refused (<c>invalid_grant</c>), or could not be asked.
|
||||
/// </exception>
|
||||
Task VerifySignInAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Reaches a host through the server, for a host set to connect that way.
|
||||
/// </summary>
|
||||
@@ -261,6 +314,10 @@ public sealed class ServerConnection : IVaultServer
|
||||
/// <inheritdoc />
|
||||
public IAccountApi Account => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task VerifySignInAsync(CancellationToken cancellationToken) =>
|
||||
tokens.RefreshNowAsync(TimeSpan.FromMinutes(1), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISyncApi Sync => Api;
|
||||
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
using System.Buffers.Text;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Storage;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>A sign-in to Spelunk that did not happen; the message says why, in words for the user.</summary>
|
||||
public sealed class SpelunkSignInException : Exception
|
||||
{
|
||||
public SpelunkSignInException()
|
||||
{
|
||||
}
|
||||
|
||||
public SpelunkSignInException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SpelunkSignInException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Signs this device in to a Spelunk, and out again.</summary>
|
||||
public interface ISpelunkAccounts
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs this device in to the Spelunk at <paramref name="apiBase"/> through the browser, and returns what
|
||||
/// to keep. Nothing is kept here; the caller stores the result.
|
||||
/// </summary>
|
||||
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
|
||||
/// <param name="cancellationToken">Abandons the sign-in — the user closing the dialog that waits for it.</param>
|
||||
/// <exception cref="SpelunkSignInException">It did not happen; the message says why.</exception>
|
||||
Task<SpelunkSignInRecord> SignInAsync(string apiBase, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tells Spelunk this device is signing out, so its sign-in ends there too. Best effort: never throws.</summary>
|
||||
Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The OAuth authorization code flow with PKCE for a native app (RFC 8252), against Spelunk as the
|
||||
/// authorization server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The browser opens Spelunk's <c>/api/app-sign-in/authorize</c>, which sends it on to Spelunk's own
|
||||
/// consent page. There the person signs in however they sign in to Spelunk — a password, a passkey, their
|
||||
/// company's single sign-on, a second factor — and allows this device. Spelunk sends the browser back to the
|
||||
/// same redirect this app's own sign-in uses (a loopback address on a desktop, the app's scheme on a phone)
|
||||
/// with a code, which is redeemed here with the PKCE verifier for a token.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not <see cref="OidcClient"/>.</b> Spelunk is no OpenID provider: it has no discovery document, and
|
||||
/// what it returns is a token for its tunnel route, not an identity. The pieces that matter for security —
|
||||
/// the callback, PKCE, the state check — are the same ones, from <c>DodoSSH.Client.Auth</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The token does not expire. Spelunk ends it when its person signs it out there, changes their password
|
||||
/// or second factors, has their sessions ended, or is removed; a ticket request then answers 401 and the
|
||||
/// sign-in is forgotten (see <c>VaultViewModel</c>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SpelunkAccounts : ISpelunkAccounts
|
||||
{
|
||||
/// <summary>What Spelunk knows this app as.</summary>
|
||||
public const string ClientId = "dodossh";
|
||||
|
||||
/// <summary>The longest token kept. Far beyond Spelunk's; a bound on what goes into a header and the cache.</summary>
|
||||
private const int MaxTokenLength = 512;
|
||||
|
||||
/// <summary>What every token Spelunk issues a signed-in app begins with, and how Spelunk tells it from an API key.</summary>
|
||||
/// <remarks>
|
||||
/// Checked rather than assumed: a token without it is one Spelunk would read as something else, so an answer
|
||||
/// carrying one is not Spelunk's app sign-in, whatever else it looks like.
|
||||
/// </remarks>
|
||||
internal const string TokenPrefix = "spelunkapp_";
|
||||
|
||||
/// <summary>The longest error body read, so a misbehaving proxy cannot hand this a gigabyte.</summary>
|
||||
private const int MaxProblemBytes = 16 * 1024;
|
||||
|
||||
/// <summary>How long signing out waits for Spelunk before forgetting the sign-in here regardless.</summary>
|
||||
private static readonly TimeSpan SignOutTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>The page the loopback listener shows once Spelunk has sent the browser back.</summary>
|
||||
private const string CompletionHtml =
|
||||
"""
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>DodoSSH</title></head>
|
||||
<body style="font-family:system-ui;margin:4rem;text-align:center">
|
||||
<h1>Done</h1><p>You can close this tab and return to DodoSSH.</p>
|
||||
</body></html>
|
||||
""";
|
||||
|
||||
/// <remarks>
|
||||
/// Redirects are not followed. The code, its verifier and the token go to the address the user signed in
|
||||
/// to and nowhere else, and a Spelunk that answered with a redirect would be asking for them to be sent on.
|
||||
/// </remarks>
|
||||
private static readonly HttpClient SharedHttp = new(
|
||||
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), AllowAutoRedirect = false })
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
|
||||
private readonly HttpClient http;
|
||||
private readonly IBrowserLauncher browser;
|
||||
private readonly Func<string, IAuthorizationCallback> callbackFactory;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly string deviceName;
|
||||
|
||||
/// <param name="browser">Opens Spelunk's sign-in.</param>
|
||||
/// <param name="callbackFactory">
|
||||
/// What waits for Spelunk's answer: the same seam as <see cref="OidcClientOptions.CallbackFactory"/>, so a
|
||||
/// phone answers through its own scheme.
|
||||
/// </param>
|
||||
/// <param name="clock">For the wait's timeout.</param>
|
||||
/// <param name="deviceName">
|
||||
/// What Spelunk is to call this device, on its consent page and under Your account; the machine's name when
|
||||
/// not given. A phone passes its model, since its machine name says nothing.
|
||||
/// </param>
|
||||
public SpelunkAccounts(
|
||||
IBrowserLauncher browser,
|
||||
Func<string, IAuthorizationCallback>? callbackFactory = null,
|
||||
TimeProvider? clock = null,
|
||||
string? deviceName = null)
|
||||
: this(SharedHttp, browser, callbackFactory, clock, deviceName)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Sign-ins whose requests are supplied, for tests.</summary>
|
||||
internal SpelunkAccounts(
|
||||
HttpClient http,
|
||||
IBrowserLauncher browser,
|
||||
Func<string, IAuthorizationCallback>? callbackFactory = null,
|
||||
TimeProvider? clock = null,
|
||||
string? deviceName = null)
|
||||
{
|
||||
this.http = http;
|
||||
this.browser = browser;
|
||||
this.callbackFactory = callbackFactory ?? (path => new LoopbackCallbackListener(path));
|
||||
this.clock = clock ?? TimeProvider.System;
|
||||
this.deviceName = string.IsNullOrWhiteSpace(deviceName) ? Environment.MachineName : deviceName.Trim();
|
||||
}
|
||||
|
||||
/// <summary>How long the browser has to come back.</summary>
|
||||
public TimeSpan BrowserTimeout { get; init; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SpelunkSignInRecord> SignInAsync(string apiBase, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!SpelunkRoute.TryNormalizeApiBase(apiBase, out var normalized, out var reason))
|
||||
{
|
||||
throw new SpelunkSignInException(reason);
|
||||
}
|
||||
|
||||
var pkce = PkcePair.Create();
|
||||
var state = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
// Disposed on the way out whatever happens, so the loopback port is closed once it has had its one
|
||||
// answer - or none.
|
||||
using var callback = callbackFactory("/callback");
|
||||
|
||||
// Read once and sent twice, byte for byte: Spelunk compares the token request's against the authorize one.
|
||||
var redirectUri = callback.RedirectUri.ToString();
|
||||
|
||||
await OpenBrowserAsync(AuthorizeUrl(normalized, redirectUri, pkce.CodeChallenge, state), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var answer = await WaitForAnswerAsync(callback, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Checked before anything else is read: an answer to some other sign-in is no answer to this one.
|
||||
if (!string.Equals(answer.State, state, StringComparison.Ordinal))
|
||||
{
|
||||
throw new SpelunkSignInException("The browser came back from a different sign-in, so it was ignored. Try again.");
|
||||
}
|
||||
|
||||
if (answer.Error is { } error)
|
||||
{
|
||||
throw new SpelunkSignInException(string.Equals(error, "access_denied", StringComparison.Ordinal)
|
||||
? "Signing in to Spelunk was cancelled in the browser."
|
||||
: $"Spelunk refused the sign-in ({SpelunkTunnelRoute.Readable(answer.ErrorDescription) ?? SpelunkTunnelRoute.Readable(error)}).");
|
||||
}
|
||||
|
||||
if (answer.Code is not { Length: > 0 } code)
|
||||
{
|
||||
throw new SpelunkSignInException("Spelunk sent the browser back without a sign-in. Try again.");
|
||||
}
|
||||
|
||||
return await RedeemAsync(normalized, code, pkce.CodeVerifier, redirectUri, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Spelunk's authorize address for this sign-in: OAuth's own parameter names, and this device's.</summary>
|
||||
private Uri AuthorizeUrl(string apiBase, string redirectUri, string codeChallenge, string state)
|
||||
{
|
||||
var query = string.Join('&', new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["client_id"] = ClientId,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["response_type"] = "code",
|
||||
["code_challenge"] = codeChallenge,
|
||||
["code_challenge_method"] = PkcePair.Method,
|
||||
["state"] = state,
|
||||
["device_name"] = deviceName,
|
||||
}.Select(pair => $"{pair.Key}={Uri.EscapeDataString(pair.Value)}"));
|
||||
|
||||
return new Uri($"{apiBase}/api/app-sign-in/authorize?{query}", UriKind.Absolute);
|
||||
}
|
||||
|
||||
private async Task OpenBrowserAsync(Uri authorize, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await browser.OpenAsync(authorize, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException))
|
||||
{
|
||||
throw new SpelunkSignInException($"The browser could not be opened to sign in to Spelunk: {exception.Message}", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits for the browser to come back, and turns every way it does not into a sentence.</summary>
|
||||
/// <exception cref="OperationCanceledException">The user abandoned the sign-in.</exception>
|
||||
private async Task<CallbackResult> WaitForAnswerAsync(IAuthorizationCallback callback, CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(BrowserTimeout, clock);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||
|
||||
try
|
||||
{
|
||||
return await callback.WaitForCallbackAsync(CompletionHtml, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException) when (timeout.IsCancellationRequested)
|
||||
{
|
||||
throw new SpelunkSignInException("Spelunk did not send the browser back in time. Try signing in again.");
|
||||
}
|
||||
catch (OperationCanceledException exception)
|
||||
{
|
||||
// Neither the user nor the clock: a phone's redirect is handed to one sign-in at a time, and another
|
||||
// one - to DodoSSH, or to Spelunk again - took it over.
|
||||
throw new SpelunkSignInException("Another sign-in started before this one finished. Try signing in again.", exception);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// A phone's browser closing without an answer arrives as an OidcException with the reason in it; a
|
||||
// desktop's listener failing to read the browser's request arrives as whatever the socket threw.
|
||||
throw new SpelunkSignInException(
|
||||
exception is OidcException ? exception.Message : $"The browser's answer from Spelunk could not be read: {exception.Message}",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SpelunkSignInRecord> RedeemAsync(
|
||||
string apiBase, string code, string verifier, string redirectUri, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, new Uri($"{apiBase}/api/app-sign-in/token", UriKind.Absolute))
|
||||
{
|
||||
Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(
|
||||
new SpelunkTokenRequestDocument { ClientId = ClientId, Code = code, CodeVerifier = verifier, RedirectUri = redirectUri },
|
||||
SpelunkAccountsJsonContext.Default.SpelunkTokenRequestDocument))
|
||||
{
|
||||
Headers = { ContentType = new MediaTypeHeaderValue("application/json") },
|
||||
},
|
||||
};
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw new SpelunkSignInException($"Spelunk could not be reached at {apiBase}: {exception.Message}", exception);
|
||||
}
|
||||
catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new SpelunkSignInException($"Spelunk at {apiBase} did not answer in time. Try again in a moment.", exception);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new SpelunkSignInException(await DescribeRefusalAsync(response, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return await ReadSignInAsync(apiBase, response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Why the token endpoint said no, in Spelunk's own words where it gave some.</summary>
|
||||
private static async Task<string> DescribeRefusalAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var detail = await ReadProblemDetailAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response.StatusCode switch
|
||||
{
|
||||
// Spelunk says which: a code that was wrong, used or expired, or sessions ended meanwhile.
|
||||
HttpStatusCode.BadRequest => "Spelunk did not accept the sign-in"
|
||||
+ (detail is null ? ". It may have taken too long; try again." : $": {detail}"),
|
||||
|
||||
HttpStatusCode.TooManyRequests =>
|
||||
"Spelunk is refusing sign-ins from this address for a moment. Wait a minute, then try again.",
|
||||
|
||||
_ => string.Create(CultureInfo.InvariantCulture, $"Spelunk would not finish the sign-in (HTTP {(int)response.StatusCode})")
|
||||
+ (detail is null ? "." : $": {detail}"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Reads the token endpoint's answer, and refuses one that is not an app sign-in's.</summary>
|
||||
private async Task<SpelunkSignInRecord> ReadSignInAsync(string apiBase, HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
SpelunkTokenDocument? token;
|
||||
try
|
||||
{
|
||||
token = await response.Content
|
||||
.ReadFromJsonAsync(SpelunkAccountsJsonContext.Default.SpelunkTokenDocument, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new SpelunkSignInException(Unintelligible, exception);
|
||||
}
|
||||
|
||||
// The token goes into a header on every ticket request, so it has to be one - and one Spelunk reads as
|
||||
// an app's.
|
||||
if (token is not { Token: { Length: > 0 and <= MaxTokenLength } value, SessionId: { } sessionId, User: { Email: { } email } user }
|
||||
|| !value.StartsWith(TokenPrefix, StringComparison.Ordinal)
|
||||
|| value.Length == TokenPrefix.Length
|
||||
|| !value.All(character => char.IsAsciiLetterOrDigit(character) || character == '_'))
|
||||
{
|
||||
throw new SpelunkSignInException(Unintelligible);
|
||||
}
|
||||
|
||||
return new SpelunkSignInRecord(apiBase, value, sessionId, email, user.DisplayName ?? email, clock.GetUtcNow());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signIn);
|
||||
|
||||
// Only to an address a sign-in could have come from: the cache is this device's own, but a record that
|
||||
// no longer normalises to itself is one to forget rather than to send a token on behalf of.
|
||||
if (!SpelunkRoute.TryNormalizeApiBase(signIn.ApiBase, out var apiBase, out _)
|
||||
|| !string.Equals(apiBase, signIn.ApiBase, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Bounded on its own: signing out of DodoSSH waits for this, and a Spelunk that does not answer must not
|
||||
// hold that up for the transport's whole timeout.
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(SignOutTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, new Uri($"{apiBase}/api/app-sign-in/session", UriKind.Absolute));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", signIn.Token);
|
||||
using var response = await http.SendAsync(request, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or OperationCanceledException or InvalidOperationException or FormatException)
|
||||
{
|
||||
// Unreachable, already signed out, or slow: either way this device forgets it, which is what signing
|
||||
// out here means. Spelunk's list under Your account still offers to end it there.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Spelunk's own words about a refusal, cut to one line, or null when it gave none.</summary>
|
||||
private static async Task<string?> ReadProblemDetailAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (body.Length is 0 or > MaxProblemBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var problem = JsonSerializer.Deserialize(body, SpelunkAccountsJsonContext.Default.SpelunkSignInProblemDocument);
|
||||
return SpelunkTunnelRoute.Readable(problem?.Detail);
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or HttpRequestException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private const string Unintelligible =
|
||||
"Spelunk answered the sign-in in a way this app does not understand. Update DodoSSH, or check that the API "
|
||||
+ "address is Spelunk's.";
|
||||
}
|
||||
|
||||
/// <summary>What the app sends to redeem its code.</summary>
|
||||
internal sealed class SpelunkTokenRequestDocument
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
public string? Code { get; set; }
|
||||
|
||||
public string? CodeVerifier { get; set; }
|
||||
|
||||
public string? RedirectUri { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A signed-in app's token, as Spelunk returns it. Nullable because it models untrusted input.</summary>
|
||||
internal sealed class SpelunkTokenDocument
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
|
||||
public Guid? SessionId { get; set; }
|
||||
|
||||
public SpelunkTokenUserDocument? User { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SpelunkTokenUserDocument
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
|
||||
public string? DisplayName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The part of an RFC 7807 refusal from Spelunk's token endpoint that is shown.</summary>
|
||||
internal sealed class SpelunkSignInProblemDocument
|
||||
{
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(SpelunkTokenRequestDocument))]
|
||||
[JsonSerializable(typeof(SpelunkTokenDocument))]
|
||||
[JsonSerializable(typeof(SpelunkSignInProblemDocument))]
|
||||
internal sealed partial class SpelunkAccountsJsonContext : JsonSerializerContext;
|
||||
@@ -11,17 +11,17 @@ namespace DodoSSH.Client.Session;
|
||||
/// <para>
|
||||
/// Spelunk puts one of these behind a "Open in DodoSSH" button on a server's page:
|
||||
/// <c>dodossh://spelunk/connect?api=…&server=…&name=…&host=…</c>. It says which Spelunk and which
|
||||
/// server, and what to call it. <b>It never carries a credential</b>, and nothing reads one out of it: the
|
||||
/// API key is the user's, chosen in the vault, and a link that could supply one would be a link that could
|
||||
/// supply somebody else's.
|
||||
/// server, and what to call it. <b>It never carries a credential</b>, and nothing reads one out of it: who
|
||||
/// connects is whoever this device signed in to that Spelunk as, and a link that could supply a credential
|
||||
/// would be a link that could supply somebody else's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Anything on the machine can hand this application a link</b> — a web page, a mail, another program —
|
||||
/// so a link is data to check, never an instruction. It is refused whole unless it is exactly this shape,
|
||||
/// and the most it can ever do is connect to a host a link made before, add one for another server on a
|
||||
/// Spelunk a saved host already sends its API key to — with that same key — or open the editor on a new
|
||||
/// one that the user then has to finish and save. It never sends an API key anywhere a saved host does
|
||||
/// not already send it. See <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||
/// Spelunk this device is already signed in to, or open the editor on a new one that the user then has to
|
||||
/// sign in for and save. It never sends a sign-in anywhere the user did not sign in to themselves. See
|
||||
/// <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
|
||||
|
||||
@@ -69,7 +69,7 @@ public interface ISpelunkTunnel
|
||||
/// </summary>
|
||||
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
|
||||
/// <param name="serverId">The server's id in Spelunk.</param>
|
||||
/// <param name="apiKey">The Spelunk API key, which authorises the ticket and is sent nowhere else.</param>
|
||||
/// <param name="token">This device's sign-in to that Spelunk, which authorises the ticket and is sent nowhere else.</param>
|
||||
/// <param name="port">
|
||||
/// The port the agent is to connect to on the server's own loopback. Not sent when signing in, where the
|
||||
/// agent starts an SSH server of its own and Spelunk ignores it.
|
||||
@@ -83,14 +83,14 @@ public interface ISpelunkTunnel
|
||||
/// <remarks>
|
||||
/// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is
|
||||
/// spent by the socket and dead thirty seconds after issue, so this is called by the connect that will
|
||||
/// use it and never ahead of one. The key is a parameter rather than a field of some request record
|
||||
/// because a record prints its fields, and a key has no business in anything that might be logged.
|
||||
/// use it and never ahead of one. The token is a parameter rather than a field of some request record
|
||||
/// because a record prints its fields, and a token has no business in anything that might be logged.
|
||||
/// </remarks>
|
||||
/// <exception cref="SpelunkTunnelException">The tunnel could not be opened; the message says why.</exception>
|
||||
Task<SpelunkTunnelRoute> OpenAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
string token,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken);
|
||||
@@ -273,10 +273,12 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bounded well short of the default hundred seconds: a ticket is one small request, and somebody
|
||||
/// watching "Connecting…" should hear that Spelunk is not answering while they still care.
|
||||
/// watching "Connecting…" should hear that Spelunk is not answering while they still care. Redirects are
|
||||
/// not followed: the sign-in's token goes to the Spelunk it came from, and a redirect is a request to send
|
||||
/// the ticket request somewhere else.
|
||||
/// </remarks>
|
||||
private static readonly HttpClient SharedHttp = new(
|
||||
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
|
||||
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), AllowAutoRedirect = false })
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
@@ -303,7 +305,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
public async Task<SpelunkTunnelRoute> OpenAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
string token,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -313,7 +315,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
throw new SpelunkTunnelException(reason);
|
||||
}
|
||||
|
||||
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(apiKey), port, signIn, cancellationToken)
|
||||
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(token), port, signIn, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var socket = await connect(
|
||||
@@ -336,32 +338,27 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
return new Uri((secure ? "wss" : "ws") + rest + path, UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trims the stored key and refuses one that could not be a header value.
|
||||
/// </summary>
|
||||
/// <summary>Refuses a stored token that could not be a header value.</summary>
|
||||
/// <remarks>
|
||||
/// A key pasted into a credential arrives with a newline behind it often enough to be worth handling, and
|
||||
/// one with a space in the middle would otherwise surface as a <see cref="FormatException"/> from inside
|
||||
/// <see cref="HttpClient"/> rather than as a sentence about the credential.
|
||||
/// One that would otherwise surface as a <see cref="FormatException"/> from inside <see cref="HttpClient"/>
|
||||
/// rather than as a sentence. It came from Spelunk and was checked then, so this is a damaged cache.
|
||||
/// </remarks>
|
||||
private static string Sanitise(string apiKey)
|
||||
private static string Sanitise(string token)
|
||||
{
|
||||
var trimmed = apiKey?.Trim() ?? string.Empty;
|
||||
|
||||
if (trimmed.Length == 0 || trimmed.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)))
|
||||
if (string.IsNullOrEmpty(token) || token.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)))
|
||||
{
|
||||
throw new SpelunkTunnelException(
|
||||
"The credential chosen for the Spelunk API key does not hold one. A Spelunk API key is one "
|
||||
+ "unbroken run of characters starting with spelunk_; paste it as the credential's password.");
|
||||
"This device's sign-in to Spelunk is damaged. Sign in to Spelunk again.",
|
||||
HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
return token;
|
||||
}
|
||||
|
||||
private async Task<SpelunkTicketDocument> IssueTicketAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
string token,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -370,7 +367,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
HttpMethod.Post,
|
||||
new Uri(apiBase + SpelunkTunnelProtocol.TicketsPath(serverId), UriKind.Absolute));
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
// Serialised up front rather than streamed, so the request carries a Content-Length: a dozen bytes
|
||||
// do not need chunked encoding, and not every proxy in front of an API accepts it.
|
||||
@@ -495,18 +492,19 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
? $": {said}"
|
||||
: ". Update DodoSSH, or check that the API address is Spelunk's."),
|
||||
|
||||
// Spelunk says why when it knows: signed out there, a changed password, sessions ended.
|
||||
HttpStatusCode.Unauthorized =>
|
||||
"Spelunk did not accept the API key. It may have been revoked or mistyped — create a new one in "
|
||||
+ "Spelunk and paste it into the credential this host uses for it.",
|
||||
"Spelunk signed this device out"
|
||||
+ (detail is null ? ". Sign in to Spelunk again." : $": {detail}"),
|
||||
|
||||
// The detail is the useful half here: it says which role the key has and which it needs.
|
||||
HttpStatusCode.Forbidden =>
|
||||
"The Spelunk API key is not allowed to open a terminal on this server"
|
||||
+ (detail is null ? ". Opening one needs a key with the Admin role." : $": {detail}"),
|
||||
"You are not allowed to open a terminal on this server"
|
||||
+ (detail is null
|
||||
? ". It is in an organisation you are not a member of, or your role on it is below Admin."
|
||||
: $": {detail}"),
|
||||
|
||||
HttpStatusCode.NotFound =>
|
||||
"Spelunk does not know this server in the API key's organisation. It may have been removed, or "
|
||||
+ "the key belongs to a different organisation.",
|
||||
"Spelunk does not know this server, or you are not allowed to see it. It may have been removed.",
|
||||
|
||||
HttpStatusCode.ServiceUnavailable =>
|
||||
"The Spelunk agent on this server is not connected, so Spelunk cannot reach it. Check that the "
|
||||
|
||||
@@ -107,6 +107,7 @@ public sealed partial class VaultSession : IAsyncDisposable
|
||||
Vault = new VaultStore(caches, clock);
|
||||
Unlock = new UnlockStore(caches, clock);
|
||||
SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
|
||||
SpelunkSignIns = new SpelunkSignInStore(caches, protector, clock);
|
||||
// The two log repositories first, and unaudited: the recorder writes through one of them, so a log
|
||||
// that logged itself would produce an entry per entry without end. IItemKind.IsAudited is what
|
||||
// actually stops it; building them first is what lets the recorder exist before the kinds that use
|
||||
@@ -125,7 +126,6 @@ public sealed partial class VaultSession : IAsyncDisposable
|
||||
Tags = new TagRepository(Items, Outbox, keyring, activity);
|
||||
Snippets = new SnippetRepository(Items, Outbox, keyring, activity);
|
||||
ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity);
|
||||
SpelunkKeys = new SpelunkKeyRepository(Items, Outbox, keyring, activity);
|
||||
}
|
||||
|
||||
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
|
||||
@@ -201,13 +201,6 @@ public sealed partial class VaultSession : IAsyncDisposable
|
||||
/// </remarks>
|
||||
public ObjectStoreRepository ObjectStores { get; }
|
||||
|
||||
/// <summary>Spelunk API keys and the addresses they may be sent to, decrypted.</summary>
|
||||
/// <remarks>
|
||||
/// Read with the rest of the vault, and at connect from that list, as a credential's password is. The
|
||||
/// address in each is what lets a link from Spelunk connect without asking.
|
||||
/// </remarks>
|
||||
public SpelunkKeyRepository SpelunkKeys { get; }
|
||||
|
||||
/// <summary>The connections this vault has recorded, decrypted.</summary>
|
||||
/// <remarks>
|
||||
/// Written through <see cref="ConnectionRecorder"/> rather than directly by anything that connects. An
|
||||
@@ -247,6 +240,13 @@ public sealed partial class VaultSession : IAsyncDisposable
|
||||
/// </remarks>
|
||||
internal RememberedSignInStore SignIn { get; }
|
||||
|
||||
/// <summary>The Spelunks this device is signed in to, and what each sign-in gave it.</summary>
|
||||
/// <remarks>
|
||||
/// Kept on this device only, sealed like the remembered sign-in above and wiped with the cache on sign-out:
|
||||
/// each device signs in to Spelunk through its own browser. See <c>SpelunkAccounts</c>.
|
||||
/// </remarks>
|
||||
public SpelunkSignInStore SpelunkSignIns { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Remembers the sign-in this machine currently holds, so a later launch can resume it.
|
||||
/// </summary>
|
||||
|
||||
@@ -24,6 +24,7 @@ internal sealed class AvaloniaTerminalAssetProvider : ITerminalAssetProvider
|
||||
("/vendor/xterm.css", "vendor/xterm.css", "text/css; charset=utf-8"),
|
||||
("/vendor/addon-fit.js", "vendor/addon-fit.js", "text/javascript; charset=utf-8"),
|
||||
("/vendor/addon-webgl.js", "vendor/addon-webgl.js", "text/javascript; charset=utf-8"),
|
||||
("/vendor/addon-unicode11.js", "vendor/addon-unicode11.js", "text/javascript; charset=utf-8"),
|
||||
];
|
||||
|
||||
private readonly Dictionary<string, TerminalAsset> assets = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -68,9 +68,9 @@ internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> e
|
||||
/// <summary>The keychain host this was, if it was one.</summary>
|
||||
/// <remarks>
|
||||
/// Null for a connection made to a typed address, and that is a real distinction rather than missing
|
||||
/// data — see <c>VaultViewModel.ConnectManuallyAsync</c>. It is what lets the Connections screen offer
|
||||
/// the right thing when one of these rows is tapped: a keychain host has a connect bar with its own
|
||||
/// authentication behind it, and an address has only the box it was typed into.
|
||||
/// data — see <c>VaultViewModel.ConnectToEndpointAsync</c>. It is what lets the connect prompt offer the
|
||||
/// right thing when one of these rows is tapped: a keychain host has its own authentication behind it, and
|
||||
/// an address has only the password typed for it — see <c>MainWindowViewModel.ChoosePromptRecentAsync</c>.
|
||||
/// </remarks>
|
||||
internal Guid? HostId => entry.Secret.HostId;
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ internal sealed partial class MainWindowViewModel
|
||||
/// Called by the head, on the UI thread, for a link this launch was started with, a link a second launch
|
||||
/// passed down the doorbell, or one the platform delivered to the running application. The link has
|
||||
/// already been checked by <see cref="SpelunkLink.TryParse"/>; what it may do is decided by
|
||||
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never sends an API key to a Spelunk no saved
|
||||
/// host already sends it to.
|
||||
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never starts a sign-in by itself, and only
|
||||
/// connects through a Spelunk this device is already signed in to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -941,6 +941,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// <summary>Whether the picker is showing hosts.</summary>
|
||||
internal bool ShowsHostPicker => Remote is RemoteKind.Host;
|
||||
|
||||
/// <summary>Whether a host — not a bucket — is open, so that a terminal could be opened beside it.</summary>
|
||||
internal bool IsHostConnected => IsConnected && Remote is RemoteKind.Host;
|
||||
|
||||
/// <summary>Whether the picker is showing buckets.</summary>
|
||||
internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;
|
||||
|
||||
@@ -1440,6 +1443,50 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
var many => $"{many} selected",
|
||||
};
|
||||
|
||||
/// <summary>Whether there is anything for an action to act on: a tick, or a chosen row.</summary>
|
||||
/// <remarks>
|
||||
/// The phone's question, and it is <see cref="RemoteBatch"/>'s own rule — ticks, or the selection when
|
||||
/// there are none — asked as a yes or no. It decides whether the files screen's selection bar is up,
|
||||
/// which is where that head keeps SAVE and the menu of the four things that change a file. Nothing on
|
||||
/// the desktop reads it.
|
||||
/// </remarks>
|
||||
internal bool HasRemoteBatch => HasCheckedRemote || SelectedRemoteEntry is not null;
|
||||
|
||||
/// <summary>What the phone's selection bar says: the count when rows are ticked, else the row's name.</summary>
|
||||
internal string RemoteBatchLabel =>
|
||||
HasCheckedRemote ? CheckedRemoteLabel : SelectedRemoteEntry?.Name ?? string.Empty;
|
||||
|
||||
/// <summary>Whether the phone's menu of things to do to the chosen rows is up.</summary>
|
||||
/// <remarks>
|
||||
/// The phone moved RENAME, MOVE, PERMS and DELETE off a permanent block of buttons — seven of them over
|
||||
/// half the screen, live with nothing chosen (audit M11) — and into a sheet raised from the selection
|
||||
/// bar's ⋮. The sheet's openness is kept here rather than in the view so the system back gesture, which
|
||||
/// the shell answers, can lower it. The desktop never sets it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isEntryMenuOpen;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenEntryMenu() => IsEntryMenuOpen = true;
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseEntryMenu() => IsEntryMenuOpen = false;
|
||||
|
||||
/// <summary>Lets go of everything chosen: the ticks and the selection both.</summary>
|
||||
/// <remarks>The ✕ on the phone's selection bar, which is how somebody says they did not mean it.</remarks>
|
||||
[RelayCommand]
|
||||
private void ClearRemoteBatch()
|
||||
{
|
||||
foreach (var row in RemoteEntries.Where(row => row.IsChecked))
|
||||
{
|
||||
row.IsChecked = false;
|
||||
}
|
||||
|
||||
SelectedRemoteEntry = null;
|
||||
IsEntryMenuOpen = false;
|
||||
RaiseBatchState();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="CheckedRemoteLabel" />
|
||||
internal string CheckedLocalLabel => CheckedLocalCount switch
|
||||
{
|
||||
@@ -1511,6 +1558,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
OnPropertyChanged(nameof(HasCheckedLocal));
|
||||
OnPropertyChanged(nameof(CheckedRemoteLabel));
|
||||
OnPropertyChanged(nameof(CheckedLocalLabel));
|
||||
OnPropertyChanged(nameof(HasRemoteBatch));
|
||||
OnPropertyChanged(nameof(RemoteBatchLabel));
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
RaiseItemActionState();
|
||||
@@ -4252,6 +4301,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// </remarks>
|
||||
partial void OnRemoteChanged(RemoteKind value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsHostConnected));
|
||||
|
||||
// PERMS is the one item action that is not offered on both destinations; see
|
||||
// CanChangeRemotePermissions.
|
||||
OnPropertyChanged(nameof(CanChangeRemotePermissions));
|
||||
@@ -4287,6 +4338,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// </remarks>
|
||||
partial void OnIsConnectedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsHostConnected));
|
||||
|
||||
if (value)
|
||||
{
|
||||
LostConnection = null;
|
||||
@@ -4314,6 +4367,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanEditRemote));
|
||||
OnPropertyChanged(nameof(SelectedRemoteName));
|
||||
OnPropertyChanged(nameof(HasRemoteBatch));
|
||||
OnPropertyChanged(nameof(RemoteBatchLabel));
|
||||
|
||||
PendingRemoteDeletion = null;
|
||||
PendingRemoteMove = null;
|
||||
|
||||
@@ -126,6 +126,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
this.restart = restart;
|
||||
|
||||
CurrentVersion = updates.CurrentVersion;
|
||||
UnsupportedReason = (updates as UnavailableUpdateChannel)?.Reason;
|
||||
isAutomatic = settings.Read().AutomaticUpdateChecks;
|
||||
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
|
||||
}
|
||||
@@ -133,6 +134,16 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
/// <summary>What this build calls itself.</summary>
|
||||
internal string CurrentVersion { get; }
|
||||
|
||||
/// <summary>Why this copy cannot replace itself, when the channel had something particular to say.</summary>
|
||||
/// <remarks>
|
||||
/// Null in the ordinary cases, where the preferences screen's own sentence about checkouts and unzipped
|
||||
/// copies is the right one. See <see cref="UnavailableUpdateChannel"/> for the case where it is not.
|
||||
/// </remarks>
|
||||
internal string? UnsupportedReason { get; }
|
||||
|
||||
/// <summary>Unsupported, with nothing more particular to say than the screen's own sentence.</summary>
|
||||
internal bool IsUnsupportedForTheUsualReason => IsUnsupported && UnsupportedReason is null;
|
||||
|
||||
[ObservableProperty]
|
||||
private UpdateState state;
|
||||
|
||||
@@ -248,6 +259,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
{
|
||||
OnPropertyChanged(nameof(IsSupported));
|
||||
OnPropertyChanged(nameof(IsUnsupported));
|
||||
OnPropertyChanged(nameof(IsUnsupportedForTheUsualReason));
|
||||
OnPropertyChanged(nameof(IsChecking));
|
||||
OnPropertyChanged(nameof(IsDownloading));
|
||||
OnPropertyChanged(nameof(IsReady));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
@@ -62,18 +61,6 @@ internal sealed partial class VaultViewModel
|
||||
[ObservableProperty]
|
||||
private string editorSpelunkServerId = string.Empty;
|
||||
|
||||
/// <summary>What the editor offers as the Spelunk API key: saved Spelunk keys, then credentials.</summary>
|
||||
/// <remarks>
|
||||
/// No "none" entry: a route with no key cannot be opened, and nothing selected is how a host made from a
|
||||
/// link says it has not been chosen yet. A key the host names that has since been deleted keeps a
|
||||
/// placeholder, for the reason <see cref="BuildAuthenticationChoices"/> keeps one.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<AuthenticationChoice> EditorSpelunkKeyChoices { get; } = [];
|
||||
|
||||
/// <summary>The credential chosen to hold the Spelunk API key.</summary>
|
||||
[ObservableProperty]
|
||||
private AuthenticationChoice? editorSelectedSpelunkKey;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to.
|
||||
/// </summary>
|
||||
@@ -119,7 +106,7 @@ internal sealed partial class VaultViewModel
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The request a route is used for is made with <see cref="Through"/>, which is where a Spelunk sign-in's
|
||||
/// key and account replace the ones the host resolved to.
|
||||
/// throwaway key and account replace the ones the host resolved to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
|
||||
@@ -170,13 +157,13 @@ internal sealed partial class VaultViewModel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a Spelunk tunnel to a host, with the key it names.
|
||||
/// Opens a Spelunk tunnel to a host, as whoever this device is signed in to that Spelunk as.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The key is read from the keychain at the moment of connecting, like every other secret the connect
|
||||
/// path uses, and goes only to the API address the host names — and for a saved Spelunk key, only when
|
||||
/// that is the address it was saved for. See <see cref="SpelunkApiKeyFor"/>.
|
||||
/// DodoSSH's own account is checked first and the sign-in read at the moment of connecting — see
|
||||
/// <see cref="SpelunkSignInForAsync"/>. The sign-in only ever goes to the Spelunk it came from, since it is
|
||||
/// found by that address. One Spelunk refuses (a 401) is forgotten, so the next attempt asks to sign in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
|
||||
@@ -194,17 +181,27 @@ internal sealed partial class VaultViewModel
|
||||
SpelunkRoute route,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var apiKey = SpelunkApiKeyFor(row, route);
|
||||
var signIn = await SpelunkSignInForAsync(row, route, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return await Spelunk
|
||||
.OpenAsync(
|
||||
route.ApiBase,
|
||||
route.ServerId,
|
||||
apiKey,
|
||||
row.Resolved.Port.Value,
|
||||
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
try
|
||||
{
|
||||
// The sign-in's own address rather than the route's, though they are one string: the token goes to
|
||||
// the Spelunk that issued it, and that is said here rather than left to the lookup that found it.
|
||||
return await Spelunk
|
||||
.OpenAsync(
|
||||
signIn.ApiBase,
|
||||
route.ServerId,
|
||||
signIn.Token,
|
||||
row.Resolved.Port.Value,
|
||||
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (SpelunkTunnelException refusal)
|
||||
{
|
||||
await ForgetRefusedSpelunkSignInAsync(route.ApiBase, refusal).ConfigureAwait(true);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -220,19 +217,17 @@ internal sealed partial class VaultViewModel
|
||||
/// a host somebody set up by hand, or one in a team's vault, is theirs to connect to and not the link's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Otherwise, when that Spelunk already gets an API key, the host is made and connected to.</b> In the
|
||||
/// personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no
|
||||
/// SSH key or password — with the key saved for that Spelunk under Settings → Security, or else the key
|
||||
/// credential a saved host already sends there. It is saved the ordinary way and syncs like any host.
|
||||
/// Nothing leaks by this: the key goes where the user already said it goes, and the server is one that
|
||||
/// key's own organisation holds or Spelunk refuses it.
|
||||
/// <b>Otherwise, when this device is signed in to that Spelunk, the host is made and connected to.</b> In
|
||||
/// the personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no
|
||||
/// SSH key or password. It is saved the ordinary way and syncs like any host. Nothing leaks by this: the
|
||||
/// sign-in goes back to the Spelunk the user signed in to, and the server is one they may reach or Spelunk
|
||||
/// refuses it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The first link from a Spelunk with no key opens the editor instead, and nothing more.</b> Anything
|
||||
/// on this machine can make a link, so it must never be able to send an API key to an address the user
|
||||
/// has not sent it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal
|
||||
/// vault — and the user chooses the key and saves; every later link from that Spelunk connects straight
|
||||
/// away.
|
||||
/// <b>A link from a Spelunk this device is not signed in to opens the editor instead, and nothing
|
||||
/// more.</b> Anything on this machine can make a link, so it must never be able to start a sign-in by
|
||||
/// itself. The host is pre-filled — signing in through Spelunk, tagged, in the personal vault — and the
|
||||
/// editor offers to sign in; every later link from that Spelunk connects straight away.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task OpenSpelunkLinkAsync(SpelunkLink link)
|
||||
@@ -251,15 +246,15 @@ internal sealed partial class VaultViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
var knownKey = KnownSpelunkKey(link.ApiBase, personal);
|
||||
var signedIn = await session.SpelunkSignIns.FindAsync(link.ApiBase, CancellationToken.None).ConfigureAwait(true) is not null;
|
||||
|
||||
if (personal is { } into && knownKey is { } key)
|
||||
if (personal is { } into && signedIn)
|
||||
{
|
||||
await AddFromLinkAndConnectAsync(link, into, key).ConfigureAwait(true);
|
||||
await AddFromLinkAndConnectAsync(link, into).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await OpenLinkInEditorAsync(link, personal, knownKey).ConfigureAwait(true);
|
||||
await OpenLinkInEditorAsync(link, personal).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>The personal vault, when this session can read and write it.</summary>
|
||||
@@ -273,33 +268,8 @@ internal sealed partial class VaultViewModel
|
||||
private static bool IsSpelunkTag(TagSecret tag) =>
|
||||
string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The key the user has already agreed may go to this Spelunk, or null when there is none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A Spelunk key saved for that address first — it is the one thing that says so in as many words — and
|
||||
/// otherwise the key a saved host already sends there, which is how the answer was reached before saved
|
||||
/// keys existed and is still true of every host set up that way.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The personal vault's first, so the answer does not depend on the order vaults happen to load in; and
|
||||
/// only a key still in the keychain, because a dangling one would make a host that cannot connect.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private Guid? KnownSpelunkKey(string apiBase, Guid? personal) =>
|
||||
SavedSpelunkKeyFor(apiBase, personal)?.EntityId
|
||||
?? Hosts
|
||||
.OrderByDescending(row => row.VaultId == personal)
|
||||
.Select(row => row.Host.Spelunk)
|
||||
.Where(route => route is not null && string.Equals(route.ApiBase, apiBase, StringComparison.Ordinal))
|
||||
.Select(route => (Guid?)route!.ApiKeyCredentialId)
|
||||
.FirstOrDefault(id =>
|
||||
Credentials.Any(credential => credential.EntityId == id)
|
||||
|| SpelunkKeys.Any(key => key.EntityId == id && string.Equals(key.ApiBase, apiBase, StringComparison.Ordinal)));
|
||||
|
||||
/// <summary>The host a link makes: what the link says, signing in through Spelunk with a known key.</summary>
|
||||
private static HostSecret HostFromLink(SpelunkLink link, Guid apiKeyCredentialId, TagSet tags) => new()
|
||||
/// <summary>The host a link makes: what the link says, signing in through Spelunk.</summary>
|
||||
private static HostSecret HostFromLink(SpelunkLink link, TagSet tags) => new()
|
||||
{
|
||||
Label = link.Name,
|
||||
Hostname = link.Hostname,
|
||||
@@ -309,13 +279,12 @@ internal sealed partial class VaultViewModel
|
||||
{
|
||||
ApiBase = link.ApiBase,
|
||||
ServerId = link.ServerId,
|
||||
ApiKeyCredentialId = apiKeyCredentialId,
|
||||
SignInThroughSpelunk = true,
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>Saves the host a link describes into the personal vault, tagged, and connects to it.</summary>
|
||||
private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId, Guid apiKeyCredentialId)
|
||||
private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
@@ -335,7 +304,7 @@ internal sealed partial class VaultViewModel
|
||||
hostId = await session.Hosts
|
||||
.CreateAsync(
|
||||
vaultId,
|
||||
HostFromLink(link, apiKeyCredentialId, TagSet.Create([tagId])),
|
||||
HostFromLink(link, TagSet.Create([tagId])),
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
@@ -358,14 +327,14 @@ internal sealed partial class VaultViewModel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the editor on a new host from a link, for the first link from a Spelunk no host sends a key to.
|
||||
/// Opens the editor on a new host from a link, for a link from a Spelunk this device is not signed in to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Opened before anything is awaited, so the editor is up in the same turn the link arrived in. The tag is
|
||||
/// made or found afterwards and added to the host being edited; a tag made for a host that is then
|
||||
/// cancelled stays, as the editor's own new-tag box leaves it.
|
||||
/// </remarks>
|
||||
private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal, Guid? knownKey)
|
||||
private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal)
|
||||
{
|
||||
// Refused while another host is half-edited, as NewHost refuses it, and said the same way: the link
|
||||
// is not worth somebody's unsaved host.
|
||||
@@ -390,14 +359,12 @@ internal sealed partial class VaultViewModel
|
||||
EditorSpelunkApi = link.ApiBase;
|
||||
EditorSpelunkServerId = link.ServerId.ToString();
|
||||
|
||||
BuildSpelunkKeyChoices(knownKey);
|
||||
|
||||
IsHostPaneOpen = true;
|
||||
|
||||
var said = EditorSelectedSpelunkKey is null
|
||||
? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose your Spelunk API "
|
||||
+ "key and save — Spelunk signs you in, so no SSH key is needed, and later links from this "
|
||||
+ "Spelunk connect straight away. Saving the key under Settings → Security does the same."
|
||||
var said = EditorSpelunkNeedsSignIn
|
||||
? $"Adding {link.Name} from Spelunk at {link.ApiBase}. Sign in to that Spelunk from the editor and "
|
||||
+ "save — Spelunk signs you in to the server, so no SSH key is needed, and later links from this "
|
||||
+ "Spelunk connect straight away."
|
||||
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save.";
|
||||
|
||||
Status = said;
|
||||
@@ -460,22 +427,16 @@ internal sealed partial class VaultViewModel
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a host needs this key or credential to connect: as its resolved binding, or as the credential
|
||||
/// holding its Spelunk API key.
|
||||
/// </summary>
|
||||
/// <summary>Whether a host needs this key or credential to connect, as its resolved binding.</summary>
|
||||
/// <remarks>
|
||||
/// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask,
|
||||
/// so a credential that is only somebody's Spelunk key is not deleted as though nothing used it. Spelunk
|
||||
/// keys are named by the host itself and never inherited, so the stored route is the whole answer there.
|
||||
/// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask.
|
||||
/// A host Spelunk signs in to uses no binding at all, whatever its group lends.
|
||||
/// </remarks>
|
||||
private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) =>
|
||||
(!row.Host.SignsInThroughSpelunk
|
||||
&& row.Resolved.Binding is { } binding
|
||||
&& binding.Kind == kind
|
||||
&& binding.EntityId == entityId)
|
||||
|| (kind is ResolvedBindingKind.Credential && row.Host.Spelunk?.ApiKeyCredentialId == entityId);
|
||||
!row.Host.SignsInThroughSpelunk
|
||||
&& row.Resolved.Binding is { } binding
|
||||
&& binding.Kind == kind
|
||||
&& binding.EntityId == entityId;
|
||||
|
||||
/// <summary>Puts a host's route into the editor, or clears it for a host that has none.</summary>
|
||||
private void LoadEditorSpelunk(SpelunkRoute? route)
|
||||
@@ -484,36 +445,9 @@ internal sealed partial class VaultViewModel
|
||||
EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false;
|
||||
EditorSpelunkApi = route?.ApiBase ?? string.Empty;
|
||||
EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty;
|
||||
EditorSpelunkSignInNote = string.Empty;
|
||||
|
||||
BuildSpelunkKeyChoices(route?.ApiKeyCredentialId);
|
||||
}
|
||||
|
||||
/// <summary>Fills the API key picker, keeping whatever the host names selectable.</summary>
|
||||
/// <remarks>
|
||||
/// Saved Spelunk keys first, named for where they go, because that is the choice the Security page made
|
||||
/// for exactly this; then every credential, for a key kept as a password before saved keys existed.
|
||||
/// </remarks>
|
||||
private void BuildSpelunkKeyChoices(Guid? selectedId)
|
||||
{
|
||||
EditorSpelunkKeyChoices.Clear();
|
||||
|
||||
foreach (var key in SpelunkKeys)
|
||||
{
|
||||
EditorSpelunkKeyChoices.Add(new AuthenticationChoice(
|
||||
AuthenticationKind.Credential, key.EntityId, key.Label, $"Spelunk key · {key.ApiBase}"));
|
||||
}
|
||||
|
||||
foreach (var credential in Credentials)
|
||||
{
|
||||
EditorSpelunkKeyChoices.Add(AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
|
||||
}
|
||||
|
||||
if (selectedId is { } missing && EditorSpelunkKeyChoices.All(choice => choice.EntityId != missing))
|
||||
{
|
||||
EditorSpelunkKeyChoices.Add(AuthenticationChoice.Missing(AuthenticationKind.Credential, missing));
|
||||
}
|
||||
|
||||
EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == selectedId);
|
||||
UpdateEditorSpelunkSignIn();
|
||||
}
|
||||
|
||||
/// <summary>The editor's route, or null when the box is not ticked.</summary>
|
||||
@@ -536,7 +470,6 @@ internal sealed partial class VaultViewModel
|
||||
? normalized
|
||||
: EditorSpelunkApi.Trim(),
|
||||
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty,
|
||||
ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty,
|
||||
SignInThroughSpelunk = EditorSpelunkSignIn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Sync;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One saved Spelunk key, as a row on the Security page.</summary>
|
||||
/// <remarks>
|
||||
/// Carries the decrypted key so the connect path needs no second decryption, and — as the credential row does
|
||||
/// — exposes nothing of it to the view: the XAML binds the name, the address and a count.
|
||||
/// </remarks>
|
||||
internal sealed class SpelunkKeyRowViewModel(VaultItem<SpelunkKeySecret> key, Guid vaultId, string vaultName)
|
||||
{
|
||||
internal Guid EntityId => key.EntityId;
|
||||
|
||||
internal Guid VaultId { get; } = vaultId;
|
||||
|
||||
internal string VaultName { get; } = vaultName;
|
||||
|
||||
internal SpelunkKeySecret Key => key.Secret;
|
||||
|
||||
internal string Label => key.Secret.Label;
|
||||
|
||||
/// <summary>The one address this key is ever sent to.</summary>
|
||||
internal string ApiBase => key.Secret.ApiBase;
|
||||
|
||||
/// <summary>The vault's name, when there is more than one to tell apart; empty otherwise.</summary>
|
||||
internal string VaultBadge { get; init; } = string.Empty;
|
||||
|
||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||
|
||||
/// <summary>How many hosts reach Spelunk with this key, in words.</summary>
|
||||
internal string UsedBy { get; init; } = string.Empty;
|
||||
|
||||
internal bool IsReadOnly => key.IsReadOnly;
|
||||
|
||||
internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spelunk keys: filling one in on the Security page, and what a saved one lets a host and a link do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A saved key says where it may go.</b> It is an API key and the one Spelunk address it belongs to, kept
|
||||
/// in the keychain and synced like any secret. A host can name it where it would name a credential, and it is
|
||||
/// then sent to that address and never to another — see <see cref="SpelunkApiKeyFor"/>. A link from that
|
||||
/// Spelunk connects straight away, because saving the key is the user saying so — see
|
||||
/// <see cref="KnownSpelunkKey"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The form saves into the personal vault, and a second key for an address the personal vault already has a
|
||||
/// key for replaces that key rather than adding a rival: one Spelunk, one key, which is also what makes the
|
||||
/// answer to "which key does a link use?" not depend on anything the user cannot see.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class VaultViewModel
|
||||
{
|
||||
/// <summary>Every Spelunk key this session can read, the personal vault's first.</summary>
|
||||
internal ObservableCollection<SpelunkKeyRowViewModel> SpelunkKeys { get; } = [];
|
||||
|
||||
/// <summary>Whether there is any saved key to list.</summary>
|
||||
internal bool HasSpelunkKeys => SpelunkKeys.Count > 0;
|
||||
|
||||
/// <summary>The Spelunk API address in the Security page's form, as typed.</summary>
|
||||
[ObservableProperty]
|
||||
private string spelunkKeyFormApi = string.Empty;
|
||||
|
||||
/// <summary>The API key in the Security page's form, as pasted. Cleared the moment it is saved.</summary>
|
||||
[ObservableProperty]
|
||||
private string spelunkKeyFormKey = string.Empty;
|
||||
|
||||
/// <summary>What the form has to say about the last save, or empty.</summary>
|
||||
/// <remarks>
|
||||
/// Beside the form rather than only on the status line, because the Security page is a long way from the
|
||||
/// status bar and a refusal nobody sees reads as a button that does nothing.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string spelunkKeyFormNote = string.Empty;
|
||||
|
||||
/// <summary>Whether <see cref="SpelunkKeyFormNote"/> has anything in it.</summary>
|
||||
internal bool HasSpelunkKeyFormNote => SpelunkKeyFormNote.Length > 0;
|
||||
|
||||
partial void OnSpelunkKeyFormNoteChanged(string value) => OnPropertyChanged(nameof(HasSpelunkKeyFormNote));
|
||||
|
||||
/// <summary>
|
||||
/// Picks the saved key for the address being typed, when nothing is chosen yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only into an empty choice: a key somebody picked is theirs, and replacing it because they edited the
|
||||
/// address would be choosing for them.
|
||||
/// </remarks>
|
||||
partial void OnEditorSpelunkApiChanged(string value)
|
||||
{
|
||||
if (EditorSelectedSpelunkKey is not null
|
||||
|| !SpelunkRoute.TryNormalizeApiBase(value, out var apiBase, out _)
|
||||
|| SavedSpelunkKeyFor(apiBase, PersonalVaultId()) is not { } saved)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == saved.EntityId);
|
||||
}
|
||||
|
||||
/// <summary>Saves the key in the form for the address in the form.</summary>
|
||||
[RelayCommand]
|
||||
private async Task SaveSpelunkKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryReadSpelunkKeyForm(out var vaultId, out var existing, out var key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var apiBase = key.ApiBase;
|
||||
|
||||
SpelunkKeyFormNote = string.Empty;
|
||||
|
||||
await RunAsync(
|
||||
"Saving the Spelunk key…",
|
||||
async () =>
|
||||
{
|
||||
if (existing is not null)
|
||||
{
|
||||
await session.SpelunkKeys
|
||||
.UpdateAsync(vaultId, existing.EntityId, key, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await session.SpelunkKeys.CreateAsync(vaultId, key, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// The key leaves the form as soon as it is in the keychain; the address stays, as a reminder
|
||||
// of what was just saved.
|
||||
SpelunkKeyFormKey = string.Empty;
|
||||
SpelunkKeyFormApi = apiBase;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
var said = existing is null
|
||||
? $"Saved the Spelunk key for {apiBase}. Links from that Spelunk now connect straight away."
|
||||
: $"Replaced the Spelunk key for {apiBase}. Every host using it connects with the new one.";
|
||||
|
||||
Status = connection() is null ? $"{said} It will sync when you are online." : said;
|
||||
SpelunkKeyFormNote = said;
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the form into the key it would save, and where; or says on the form why it cannot.
|
||||
/// </summary>
|
||||
/// <param name="vaultId">The vault the key goes into: the personal one, when there is one.</param>
|
||||
/// <param name="existing">The key already saved there for that address, which this one replaces.</param>
|
||||
/// <param name="key">The key to save, keeping the existing one's name.</param>
|
||||
private bool TryReadSpelunkKeyForm(
|
||||
out Guid vaultId,
|
||||
out SpelunkKeyRowViewModel? existing,
|
||||
[NotNullWhen(true)] out SpelunkKeySecret? key)
|
||||
{
|
||||
vaultId = Guid.Empty;
|
||||
existing = null;
|
||||
key = null;
|
||||
|
||||
if (!SpelunkRoute.TryNormalizeApiBase(SpelunkKeyFormApi, out var apiBase, out var reason))
|
||||
{
|
||||
SpelunkKeyFormNote = reason;
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((PersonalVaultId() ?? WritableActiveVault()) is not { } target)
|
||||
{
|
||||
SpelunkKeyFormNote = "There is no vault here this account can write to, so the key cannot be saved.";
|
||||
return false;
|
||||
}
|
||||
|
||||
vaultId = target;
|
||||
existing = SpelunkKeys.FirstOrDefault(row =>
|
||||
row.VaultId == target && string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal));
|
||||
|
||||
var candidate = new SpelunkKeySecret
|
||||
{
|
||||
Label = existing?.Label ?? SpelunkKeyLabel(apiBase),
|
||||
ApiBase = apiBase,
|
||||
ApiKey = SpelunkKeyFormKey.Trim(),
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out reason))
|
||||
{
|
||||
SpelunkKeyFormNote = reason;
|
||||
return false;
|
||||
}
|
||||
|
||||
key = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Asks whether a saved Spelunk key should go.</summary>
|
||||
[RelayCommand]
|
||||
private void DeleteSpelunkKeyRow(SpelunkKeyRowViewModel? row)
|
||||
{
|
||||
if (row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PendingDeletion = new DeletionRequest(
|
||||
DeletionTarget.SpelunkKey,
|
||||
row.EntityId,
|
||||
$"Delete the Spelunk key for {row.ApiBase}?",
|
||||
HowFarADeletionGoes("The key"),
|
||||
HostsReachingSpelunkWith(row.EntityId));
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for the Spelunk key that was agreed to.</summary>
|
||||
private async Task DeleteSpelunkKeyNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (SpelunkKeys.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||
{
|
||||
Status = "That Spelunk key is no longer here, so nothing was deleted.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Deleting…",
|
||||
async () =>
|
||||
{
|
||||
await session.SpelunkKeys
|
||||
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
Status = $"Deleted the Spelunk key for {row.ApiBase}.";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <returns>How many Spelunk keys would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// Read after the hosts, because each row says how many of them use it.
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadSpelunkKeysAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var unreadable = 0;
|
||||
var rows = new List<SpelunkKeyRowViewModel>();
|
||||
|
||||
var readableVaults = session.ReadableVaults.ToList();
|
||||
var several = readableVaults.Count > 1;
|
||||
|
||||
foreach (var vault in readableVaults)
|
||||
{
|
||||
var listing = await session.SpelunkKeys
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
rows.AddRange(listing.Items.Select(item => new SpelunkKeyRowViewModel(item, vault.VaultId, vault.Name)
|
||||
{
|
||||
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||||
UsedBy = UsedBySummary(Hosts.Count(host => host.Host.Spelunk?.ApiKeyCredentialId == item.EntityId)),
|
||||
}));
|
||||
}
|
||||
|
||||
var personal = PersonalVaultId();
|
||||
|
||||
SpelunkKeys.Clear();
|
||||
|
||||
foreach (var row in rows
|
||||
.OrderByDescending(row => row.VaultId == personal)
|
||||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||||
.ThenBy(row => row.ApiBase, StringComparer.Ordinal))
|
||||
{
|
||||
SpelunkKeys.Add(row);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasSpelunkKeys));
|
||||
|
||||
return unreadable;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The key to send to a host's Spelunk: a saved Spelunk key's, or a credential's password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A saved Spelunk key goes to its own address or nowhere.</b> A host naming one with a different
|
||||
/// address — edited by hand, or merged from two devices — is refused in words rather than sending the key
|
||||
/// to a Spelunk it was never saved for. A credential carries no address, so its password goes where the
|
||||
/// host says, as it always has.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Either can be gone, deleted on another machine, and that is refused exactly as a missing SSH credential
|
||||
/// is — see <see cref="TryBuildAuthentication"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="SpelunkTunnelException">The key cannot be sent to this host's Spelunk.</exception>
|
||||
private string SpelunkApiKeyFor(HostRowViewModel row, SpelunkRoute route)
|
||||
{
|
||||
if (SpelunkKeys.FirstOrDefault(key => key.EntityId == route.ApiKeyCredentialId) is { } saved)
|
||||
{
|
||||
if (!string.Equals(saved.ApiBase, route.ApiBase, StringComparison.Ordinal))
|
||||
{
|
||||
throw new SpelunkTunnelException(
|
||||
$"'{row.Label}' is reached through {route.ApiBase} with the Spelunk key saved for "
|
||||
+ $"{saved.ApiBase}, and that key is never sent anywhere else. Edit the host to choose "
|
||||
+ "the key for its own Spelunk.");
|
||||
}
|
||||
|
||||
return saved.Key.ApiKey;
|
||||
}
|
||||
|
||||
if (Credentials.FirstOrDefault(credential => credential.EntityId == route.ApiKeyCredentialId) is { } credential)
|
||||
{
|
||||
return credential.Credential.Password;
|
||||
}
|
||||
|
||||
throw new SpelunkTunnelException(
|
||||
$"'{row.Label}' is reached through Spelunk with an API key that is not in this keychain any more. "
|
||||
+ "Edit the host to choose another one, or save the key under Settings → Security.");
|
||||
}
|
||||
|
||||
/// <summary>The saved key for a Spelunk, the given vault's first, or null when none is saved.</summary>
|
||||
private SpelunkKeyRowViewModel? SavedSpelunkKeyFor(string apiBase, Guid? preferredVault) =>
|
||||
SpelunkKeys
|
||||
.Where(row => string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal))
|
||||
.OrderByDescending(row => row.VaultId == preferredVault)
|
||||
.FirstOrDefault();
|
||||
|
||||
/// <summary>The active vault when this session may write to it, for a session with no personal vault.</summary>
|
||||
private Guid? WritableActiveVault() =>
|
||||
session.ReadableVaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId && vault.CanWrite)
|
||||
?.VaultId;
|
||||
|
||||
/// <summary>The name a key saved from the form is given: where it goes.</summary>
|
||||
private static string SpelunkKeyLabel(string apiBase) =>
|
||||
Uri.TryCreate(apiBase, UriKind.Absolute, out var uri) ? $"Spelunk at {uri.Authority}" : "Spelunk";
|
||||
|
||||
private static string UsedBySummary(int hosts) => hosts switch
|
||||
{
|
||||
0 => "No host uses it yet",
|
||||
1 => "1 host uses it",
|
||||
_ => $"{hosts} hosts use it",
|
||||
};
|
||||
|
||||
/// <summary>What the hosts that reach Spelunk with a key would be left with, or nothing when none do.</summary>
|
||||
private string HostsReachingSpelunkWith(Guid entityId)
|
||||
{
|
||||
var named = Hosts
|
||||
.Where(row => row.Host.Spelunk?.ApiKeyCredentialId == entityId)
|
||||
.Select(row => row.Label)
|
||||
.ToArray();
|
||||
|
||||
if (named.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var list = named.Length <= 3
|
||||
? string.Join(", ", named)
|
||||
: $"{string.Join(", ", named.Take(3))} and {named.Length - 3} more";
|
||||
|
||||
return named.Length == 1
|
||||
? $"{list} reaches Spelunk with it, and will refuse to connect until it names another key."
|
||||
: $"{named.Length} hosts reach Spelunk with it — {list} — and will refuse to connect until they "
|
||||
+ "name another key.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Net;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Storage;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One Spelunk this device is signed in to, as a row on the Security page.</summary>
|
||||
/// <remarks>Carries the token for the sign-out that needs it, and shows nothing of it: the XAML binds the address and who.</remarks>
|
||||
internal sealed class SpelunkSignInRowViewModel(SpelunkSignInRecord record, int hosts)
|
||||
{
|
||||
internal SpelunkSignInRecord Record => record;
|
||||
|
||||
internal string ApiBase => record.ApiBase;
|
||||
|
||||
/// <summary>Who this device is signed in as there.</summary>
|
||||
internal string Who => string.Equals(record.DisplayName, record.Email, StringComparison.Ordinal)
|
||||
? record.Email
|
||||
: $"{record.DisplayName} · {record.Email}";
|
||||
|
||||
/// <summary>How many hosts reach Spelunk through it, in words.</summary>
|
||||
internal string UsedBy { get; } = hosts switch
|
||||
{
|
||||
0 => "No host uses it yet",
|
||||
1 => "1 host uses it",
|
||||
_ => $"{hosts} hosts use it",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing this device in to Spelunk, and what a sign-in lets a host and a link do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Per device, through the browser.</b> Each device signs in to a Spelunk once — Settings → Security, or the
|
||||
/// button in a Spelunk host's editor — and Spelunk asks its person, on its own page, whether to let this
|
||||
/// device in. What comes back is kept on this device only (<see cref="VaultSession.SpelunkSignIns"/>) and
|
||||
/// opens terminals on that Spelunk's servers in every organisation its person is in, as them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It ends with the person.</b> Spelunk refuses it once they are removed, sign it out, or change their
|
||||
/// password — a 401, after which it is forgotten here and the user is asked to sign in again. And DodoSSH
|
||||
/// checks its own account before every Spelunk connection (<see cref="VerifyDodoSshSignInAsync"/>), so an
|
||||
/// account removed from DodoSSH stops reaching Spelunk too.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class VaultViewModel
|
||||
{
|
||||
/// <summary>What signs in to Spelunk when no caller supplied something else: the system browser and a loopback listener.</summary>
|
||||
private static readonly ISpelunkAccounts DefaultSpelunkAccounts = new SpelunkAccounts(new SystemBrowserLauncher());
|
||||
|
||||
/// <summary>The Spelunks this device is signed in to.</summary>
|
||||
internal ObservableCollection<SpelunkSignInRowViewModel> SpelunkSignIns { get; } = [];
|
||||
|
||||
/// <summary>Whether there is any sign-in to list.</summary>
|
||||
internal bool HasSpelunkSignIns => SpelunkSignIns.Count > 0;
|
||||
|
||||
/// <summary>The Spelunk API address in the Security page's form, as typed.</summary>
|
||||
[ObservableProperty]
|
||||
private string spelunkSignInFormApi = string.Empty;
|
||||
|
||||
/// <summary>What the Security page's form has to say about the last sign-in, or empty.</summary>
|
||||
/// <remarks>
|
||||
/// Beside the form rather than only on the status line, because the Security page is a long way from the
|
||||
/// status bar and a refusal nobody sees reads as a button that does nothing.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasSpelunkSignInFormNote))]
|
||||
private string spelunkSignInFormNote = string.Empty;
|
||||
|
||||
/// <summary>Whether a sign-in is waiting for the browser.</summary>
|
||||
[ObservableProperty]
|
||||
private bool isSigningInToSpelunk;
|
||||
|
||||
/// <summary>Who the editor's Spelunk would be reached as from this device, or why it would not be.</summary>
|
||||
[ObservableProperty]
|
||||
private string editorSpelunkSignInNote = string.Empty;
|
||||
|
||||
/// <summary>Whether this device still has to sign in to the Spelunk the editor names.</summary>
|
||||
[ObservableProperty]
|
||||
private bool editorSpelunkNeedsSignIn;
|
||||
|
||||
internal bool HasSpelunkSignInFormNote => SpelunkSignInFormNote.Length > 0;
|
||||
|
||||
/// <inheritdoc cref="DefaultSpelunkAccounts" />
|
||||
private ISpelunkAccounts SpelunkAccounts => spelunkAccounts ?? DefaultSpelunkAccounts;
|
||||
|
||||
partial void OnEditorSpelunkApiChanged(string value) => UpdateEditorSpelunkSignIn();
|
||||
|
||||
/// <summary>Signs this device in to the Spelunk in the Security page's form.</summary>
|
||||
[RelayCommand(IncludeCancelCommand = true)]
|
||||
private async Task SignInToSpelunkAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
SpelunkSignInFormNote = string.Empty;
|
||||
|
||||
if (!SpelunkRoute.TryNormalizeApiBase(SpelunkSignInFormApi, out var apiBase, out var reason))
|
||||
{
|
||||
SpelunkSignInFormNote = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
SpelunkSignInFormApi = apiBase;
|
||||
SpelunkSignInFormNote = await SignInToSpelunkCoreAsync(apiBase, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Signs this device in to the Spelunk the host editor names.</summary>
|
||||
[RelayCommand(IncludeCancelCommand = true)]
|
||||
private async Task SignInToEditorSpelunkAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!SpelunkRoute.TryNormalizeApiBase(EditorSpelunkApi, out var apiBase, out var reason))
|
||||
{
|
||||
EditorSpelunkSignInNote = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
EditorSpelunkSignInNote = await SignInToSpelunkCoreAsync(apiBase, cancellationToken).ConfigureAwait(true);
|
||||
UpdateEditorSpelunkSignIn(keepNote: true);
|
||||
}
|
||||
|
||||
/// <summary>Signs this device out of one Spelunk: there, when it can be reached, and here regardless.</summary>
|
||||
[RelayCommand]
|
||||
private async Task SignOutOfSpelunkAsync(SpelunkSignInRowViewModel? row)
|
||||
{
|
||||
if (row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SpelunkAccounts.SignOutAsync(row.Record, CancellationToken.None).ConfigureAwait(true);
|
||||
await session.SpelunkSignIns.ForgetAsync(row.ApiBase, CancellationToken.None).ConfigureAwait(true);
|
||||
await ReloadSpelunkSignInsAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
Status = $"Signed out of Spelunk at {row.ApiBase}.";
|
||||
SpelunkSignInFormNote = Status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signs out of every Spelunk this device is signed in to — on signing out of DodoSSH, and when DodoSSH
|
||||
/// finds its own account refused. Best effort there, certain here.
|
||||
/// </summary>
|
||||
internal async Task SignOutOfEverySpelunkAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var signIn in await session.SpelunkSignIns.ListAsync(cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
await SpelunkAccounts.SignOutAsync(signIn, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
await session.SpelunkSignIns.ForgetAllAsync(cancellationToken).ConfigureAwait(true);
|
||||
await ReloadSpelunkSignInsAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>The whole of a sign-in, from either place it starts; returns what to say about it.</summary>
|
||||
/// <remarks>
|
||||
/// <b>DodoSSH has to be online and signed in to start one</b>, for the reason it has to be to use one:
|
||||
/// Spelunk is reached as somebody this DodoSSH account vouches for on this device.
|
||||
/// </remarks>
|
||||
private async Task<string> SignInToSpelunkCoreAsync(string apiBase, CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsSigningInToSpelunk)
|
||||
{
|
||||
return "A sign-in to Spelunk is already waiting for the browser.";
|
||||
}
|
||||
|
||||
IsSigningInToSpelunk = true;
|
||||
|
||||
try
|
||||
{
|
||||
await VerifyDodoSshSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Finish signing in to Spelunk at {apiBase} in your browser…";
|
||||
|
||||
var signIn = await SpelunkAccounts.SignInAsync(apiBase, cancellationToken).ConfigureAwait(true);
|
||||
await session.SpelunkSignIns.SaveAsync(signIn, cancellationToken).ConfigureAwait(true);
|
||||
await ReloadSpelunkSignInsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Signed in to Spelunk at {apiBase} as {signIn.Email}. Links from that Spelunk now connect straight away.";
|
||||
return Status;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Status = "Signing in to Spelunk was cancelled.";
|
||||
return Status;
|
||||
}
|
||||
catch (Exception exception) when (exception is SpelunkSignInException or SpelunkTunnelException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
return Status;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSigningInToSpelunk = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses unless DodoSSH is online and its account still signed in — asked of the identity provider
|
||||
/// itself, at most once a minute (see <see cref="IVaultServer.VerifySignInAsync"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An account the provider refuses is treated as signed out everywhere it reaches: every Spelunk sign-in
|
||||
/// on this device is ended, and the shell forgets the DodoSSH sign-in and goes offline (see
|
||||
/// <c>accountRefused</c>). Anything short of a refusal is a check that could not be made, and a Spelunk
|
||||
/// connection waits for one that can rather than going ahead unchecked.
|
||||
/// </remarks>
|
||||
/// <exception cref="SpelunkTunnelException">Not online, not signed in, or refused; the message says which.</exception>
|
||||
private async Task VerifyDodoSshSignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||
{
|
||||
throw new SpelunkTunnelException(
|
||||
"Connecting through Spelunk needs DodoSSH online and signed in, so that your account can be "
|
||||
+ "checked first. " + (OfflineReason ?? "This machine is offline."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await server.VerifySignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Asked again only for what asking does to a connection already held: the shell writes down the
|
||||
// refresh token the check may just have rotated, which is the only one the next launch can use.
|
||||
// Left for the next sync pass, a crash in between would cost a browser sign-in.
|
||||
await ResolveServerAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (OidcException exception) when (string.Equals(exception.ErrorCode, "invalid_grant", StringComparison.Ordinal))
|
||||
{
|
||||
await SignOutOfEverySpelunkAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
if (accountRefused is { } refused)
|
||||
{
|
||||
await refused(exception.Message, CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
throw new SpelunkTunnelException(
|
||||
"Your DodoSSH account no longer signs in, so this device has been signed out of DodoSSH and of "
|
||||
+ "Spelunk. Sign in again from Preferences.");
|
||||
}
|
||||
catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or SpelunkTunnelException))
|
||||
{
|
||||
throw new SpelunkTunnelException(
|
||||
$"DodoSSH could not check that you are still signed in ({exception.Message}). Connecting through "
|
||||
+ "Spelunk needs that check; try again once the server can be reached.",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What to reach a host's Spelunk with: this device's sign-in there, once DodoSSH's own has been checked.
|
||||
/// </summary>
|
||||
/// <exception cref="SpelunkTunnelException">There is none, or DodoSSH's own was refused.</exception>
|
||||
private async Task<SpelunkSignInRecord> SpelunkSignInForAsync(
|
||||
HostRowViewModel row,
|
||||
SpelunkRoute route,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await VerifyDodoSshSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return await session.SpelunkSignIns.FindAsync(route.ApiBase, cancellationToken).ConfigureAwait(true)
|
||||
?? throw new SpelunkTunnelException(
|
||||
$"'{row.Label}' is reached through Spelunk at {route.ApiBase}, and this device is not signed in "
|
||||
+ "there. Sign in under Settings → Security → Spelunk, or from the host's editor.");
|
||||
}
|
||||
|
||||
/// <summary>Forgets a sign-in Spelunk refused, so the next attempt asks for a new one instead of repeating the refusal.</summary>
|
||||
private async Task ForgetRefusedSpelunkSignInAsync(string apiBase, SpelunkTunnelException refusal)
|
||||
{
|
||||
if (refusal.StatusCode != HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await session.SpelunkSignIns.ForgetAsync(apiBase, CancellationToken.None).ConfigureAwait(true);
|
||||
await ReloadSpelunkSignInsAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <remarks>Read after the hosts, because each row says how many of them reach Spelunk through it.</remarks>
|
||||
internal async Task ReloadSpelunkSignInsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var signIns = await session.SpelunkSignIns.ListAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
SpelunkSignIns.Clear();
|
||||
|
||||
foreach (var signIn in signIns)
|
||||
{
|
||||
SpelunkSignIns.Add(new SpelunkSignInRowViewModel(
|
||||
signIn,
|
||||
Hosts.Count(row => string.Equals(row.Host.Spelunk?.ApiBase, signIn.ApiBase, StringComparison.Ordinal))));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasSpelunkSignIns));
|
||||
UpdateEditorSpelunkSignIn(keepNote: true);
|
||||
}
|
||||
|
||||
/// <summary>Says, in the host editor, whether this device can reach the Spelunk it names.</summary>
|
||||
/// <param name="keepNote">Whether to leave a sign-in's own outcome in place rather than restating the state.</param>
|
||||
private void UpdateEditorSpelunkSignIn(bool keepNote = false)
|
||||
{
|
||||
if (!SpelunkRoute.TryNormalizeApiBase(EditorSpelunkApi, out var apiBase, out _))
|
||||
{
|
||||
EditorSpelunkNeedsSignIn = false;
|
||||
EditorSpelunkSignInNote = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var signIn = SpelunkSignIns.FirstOrDefault(row => string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal));
|
||||
EditorSpelunkNeedsSignIn = signIn is null;
|
||||
|
||||
if (keepNote && EditorSpelunkSignInNote.Length > 0 && signIn is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EditorSpelunkSignInNote = signIn is null
|
||||
? $"This device is not signed in to Spelunk at {apiBase}. Sign in to connect — Spelunk asks in your browser."
|
||||
: $"This device reaches it as {signIn.Who}.";
|
||||
}
|
||||
}
|
||||
@@ -1343,9 +1343,6 @@ internal enum DeletionTarget
|
||||
|
||||
/// <summary>A tag, from the vault screen.</summary>
|
||||
Tag,
|
||||
|
||||
/// <summary>A saved Spelunk key, from the Security page.</summary>
|
||||
SpelunkKey,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1486,7 +1483,9 @@ internal sealed partial class VaultViewModel(
|
||||
ConnectionRecorder? connectionLog = null,
|
||||
VaultVisibility? visibility = null,
|
||||
ISshReachabilityProbe? reachability = null,
|
||||
ISpelunkTunnel? spelunk = null) : ObservableObject, IAsyncDisposable
|
||||
ISpelunkTunnel? spelunk = null,
|
||||
ISpelunkAccounts? spelunkAccounts = null,
|
||||
Func<string, CancellationToken, Task>? accountRefused = null) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
||||
/// <remarks>
|
||||
@@ -2229,6 +2228,7 @@ internal sealed partial class VaultViewModel(
|
||||
// appearing inside a group's name field is the kind of thing that only shows up in a bug report.
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEditingAVaultItem))]
|
||||
private bool isEditingTag;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -3974,6 +3974,7 @@ internal sealed partial class VaultViewModel(
|
||||
// sharing them would mean a half-typed host reappearing inside a key editor.
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEditingAVaultItem))]
|
||||
private bool isEditingKey;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -4012,6 +4013,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// this application that writes a key, and it is still SAVE.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEditingAVaultItem))]
|
||||
private bool isGeneratingKey;
|
||||
|
||||
/// <summary>
|
||||
@@ -4039,6 +4041,7 @@ internal sealed partial class VaultViewModel(
|
||||
// them would mean a half-typed key reappearing inside a credential.
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEditingAVaultItem))]
|
||||
private bool isEditingCredential;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -4069,6 +4072,7 @@ internal sealed partial class VaultViewModel(
|
||||
// A fourth set, on the same reasoning as the third.
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEditingAVaultItem))]
|
||||
private bool isEditingObjectStore;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -4246,32 +4250,52 @@ internal sealed partial class VaultViewModel(
|
||||
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
|
||||
private bool isAskingForConnectPassword;
|
||||
|
||||
/// <summary>What was typed into the manual connect box, as <c>user@host</c> or <c>user@host:port</c>.</summary>
|
||||
/// <summary>
|
||||
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One box rather than four, because this is the form of an address people already have: it is what a
|
||||
/// colleague pastes into a chat window and what `ssh` itself takes. Splitting it into user, host and port
|
||||
/// would make the ordinary case three taps between three keyboards on a phone.
|
||||
/// <para>
|
||||
/// <b>The phone's version of the desktop palette's typed-address row.</b> The connect prompt on the
|
||||
/// terminal screen is the other way in, and it is only drawn while nothing is open — a second machine
|
||||
/// reached while a first one's session is up had no box at all. The search well at the top of HOSTS is
|
||||
/// always there, the design already calls it "Search or connect…", and <c>user@host</c> is what people
|
||||
/// type into a box, so it reads the address rather than the screen growing a second field.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Read with <see cref="TryParseManualTarget"/>, the prompt's and the palette's parser too, and only for a
|
||||
/// filter with an <c>@</c> in it: without one, everything typed is a search, and a card offering to dial
|
||||
/// three letters would arrive on every keystroke.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string manualTarget = string.Empty;
|
||||
[NotifyPropertyChangedFor(nameof(HasFilterAddress))]
|
||||
[NotifyPropertyChangedFor(nameof(FilterAddressText))]
|
||||
private ManualEndpoint? filterAddress;
|
||||
|
||||
/// <inheritdoc cref="ConnectPassword" />
|
||||
/// <remarks>
|
||||
/// Its own box rather than <see cref="ConnectPassword"/>, for the reason <c>TryBuildConnectionRequest</c>
|
||||
/// takes the typed password as a parameter: these are different screens, and a password typed on one is
|
||||
/// not a password offered on the other.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string manualPassword = string.Empty;
|
||||
/// <summary>Whether the search box holds an address that can be connected to.</summary>
|
||||
internal bool HasFilterAddress => FilterAddress is not null;
|
||||
|
||||
/// <summary>Why the manual box refused, if it did.</summary>
|
||||
/// <summary>The typed address as it will be dialled, port and all.</summary>
|
||||
internal string FilterAddressText => FilterAddress?.Address ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Why a search with an <c>@</c> in it is not an address, or why its CONNECT was refused; empty otherwise.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasFilterAddressRefusal))]
|
||||
private string filterAddressRefusal = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a refusal to show under the search box.</summary>
|
||||
internal bool HasFilterAddressRefusal => FilterAddressRefusal.Length > 0;
|
||||
|
||||
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
|
||||
/// <remarks>
|
||||
/// Beside that box rather than only on <see cref="Status"/>. The refusals here are about what was typed
|
||||
/// — a missing account, a port that is not a number — and a sentence about a text box belongs next to
|
||||
/// the text box, not on a status line that also carries what the sync engine is doing.
|
||||
/// Its own box rather than <see cref="ConnectPassword"/>: these are different screens, and a password
|
||||
/// typed on one is not a password offered on the other. Cleared when the address changes, so the next
|
||||
/// machine typed never arrives with the last one's password already in the box.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string manualStatus = string.Empty;
|
||||
private string filterAddressPassword = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
|
||||
@@ -4325,9 +4349,21 @@ internal sealed partial class VaultViewModel(
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
SelectedHost is null
|
||||
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
||||
SelectedHost is null || AsksForAPassword(SelectedHost);
|
||||
|
||||
/// <summary>Whether connecting to this host needs a password typed for it.</summary>
|
||||
/// <remarks>
|
||||
/// The per-row half of <see cref="SelectedHostAsksForAPassword"/>, and the same reading for the same
|
||||
/// reasons: the resolved binding rather than the host's own ids, and never for a host Spelunk signs in to.
|
||||
/// The desktop's terminal prompt asks it of a host named there, which is not the selected one.
|
||||
/// </remarks>
|
||||
internal static bool AsksForAPassword(HostRowViewModel row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
return !row.Host.SignsInThroughSpelunk
|
||||
&& row.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the terminal column says in place of the password box, or nothing when the box is showing.
|
||||
@@ -4559,7 +4595,7 @@ internal sealed partial class VaultViewModel(
|
||||
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
|
||||
unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true);
|
||||
unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true);
|
||||
unreadable += await ReloadSpelunkKeysAsync(cancellationToken).ConfigureAwait(true);
|
||||
await ReloadSpelunkSignInsAsync(cancellationToken).ConfigureAwait(true);
|
||||
unreadable += await ReloadSnippetsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Last, because it reads the host list to work out which pins nothing dials any more.
|
||||
@@ -4614,7 +4650,6 @@ internal sealed partial class VaultViewModel(
|
||||
// longer say what the user chose.
|
||||
var authentication = EditorSelectedAuthentication;
|
||||
var groupId = EditorSelectedGroup?.EntityId;
|
||||
var spelunkKeyId = EditorSelectedSpelunkKey?.EntityId;
|
||||
|
||||
BuildTagChoices();
|
||||
|
||||
@@ -4633,8 +4668,6 @@ internal sealed partial class VaultViewModel(
|
||||
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
|
||||
asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
|
||||
grouped: EditorSelectedGroup?.EntityId is not null);
|
||||
|
||||
BuildSpelunkKeyChoices(spelunkKeyId);
|
||||
}
|
||||
|
||||
if (IsEditingGroup)
|
||||
@@ -6194,8 +6227,10 @@ internal sealed partial class VaultViewModel(
|
||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||
{
|
||||
LastSyncFailed = true;
|
||||
Status = "Offline. Changes are queued and will be sent as soon as this machine "
|
||||
+ "can reach the server again.";
|
||||
Status = OfflineReason is { } reason
|
||||
? $"Offline. {reason}"
|
||||
: "Offline. Changes are queued and will be sent as soon as this machine "
|
||||
+ "can reach the server again.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6209,6 +6244,14 @@ internal sealed partial class VaultViewModel(
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Why the last attempt to get online failed, or null when nothing has said.</summary>
|
||||
/// <remarks>
|
||||
/// Set by the shell, which is the thing that knows: <see cref="ServerReconnectHandler" /> answers null
|
||||
/// for every reason on purpose, so that a background pass stays quiet. A press of SYNC NOW is somebody
|
||||
/// asking, though, and "offline" with no why is what left a machine stuck with no way to find out.
|
||||
/// </remarks>
|
||||
internal string? OfflineReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Finds a server to sync against, getting this machine online if it is not.
|
||||
/// </summary>
|
||||
@@ -6655,6 +6698,44 @@ internal sealed partial class VaultViewModel(
|
||||
[RelayCommand]
|
||||
private void CloseAddSheet() => IsAddSheetOpen = false;
|
||||
|
||||
/// <summary>Whether any of the keychain's five editors is open: a key, a generated key, a password,
|
||||
/// a tag or a bucket.</summary>
|
||||
/// <remarks>
|
||||
/// The phone draws the five as one page over the whole screen, as it draws the host editor, and stands its
|
||||
/// own chrome down for it; this is the one question that page and that chrome ask. It is
|
||||
/// <see cref="AVaultEditorIsInTheWay"/>'s test without the status line that method writes.
|
||||
/// </remarks>
|
||||
internal bool IsEditingAVaultItem =>
|
||||
IsEditingKey || IsEditingCredential || IsGeneratingKey || IsEditingObjectStore || IsEditingTag;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the phone's sheet of things to add to the keychain is up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The keychain on the phone could add nothing — its empty state said "add an SSH key or a password" over
|
||||
/// a screen with no way to (audit M9) — although every editor it needed was already here for the desktop.
|
||||
/// The phone's floating + raises this sheet, which offers the desktop's five, and choosing one lowers it
|
||||
/// and opens that editor. The desktop's own + is a flyout and never sets this.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isKeychainAddSheetOpen;
|
||||
|
||||
/// <summary>Raises the keychain's add sheet, unless an editor is already open.</summary>
|
||||
[RelayCommand]
|
||||
private void OpenKeychainAddSheet()
|
||||
{
|
||||
if (AVaultEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsKeychainAddSheetOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>Lowers the keychain's add sheet without choosing anything.</summary>
|
||||
[RelayCommand]
|
||||
private void CloseKeychainAddSheet() => IsKeychainAddSheetOpen = false;
|
||||
|
||||
// ---- ◆ Choosing hosts, and the seven things the bar can do to them ----
|
||||
|
||||
/// <summary>
|
||||
@@ -7707,40 +7788,6 @@ internal sealed partial class VaultViewModel(
|
||||
IsHostPaneOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises whatever the head in front of the user uses to ask about one host.
|
||||
/// </summary>
|
||||
/// <param name="row">The machine, which the caller is holding.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>One command, two pieces of furniture, and it exists because the phone's has changed.</b> The
|
||||
/// desktop's answer is the drawer beside the grid and the phone's used to be the connect bar — one flag
|
||||
/// served both. The bar is gone: the phone's answer is now a tick and the action bar across the top, and
|
||||
/// that is a different piece of state, so a caller that only opened the pane would leave this head
|
||||
/// arriving at a list with nothing on it to press.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both are set rather than branching on which head is running, because neither view model knows and
|
||||
/// neither should have to. The one the head does not draw is inert: the desktop draws no ticks, and the
|
||||
/// phone has no drawer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The one caller is <c>MainWindowViewModel.ConnectToRecent</c>, and what it is doing is the deliberate
|
||||
/// act both flags exist to tell apart from browsing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void AskAboutHost(HostRowViewModel? row)
|
||||
{
|
||||
if (row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OpenHostPane(row);
|
||||
ChooseHost(row);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the drawer away, whichever of the three panels is in it.
|
||||
/// </summary>
|
||||
@@ -11096,10 +11143,6 @@ internal sealed partial class VaultViewModel(
|
||||
await DeleteTagNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||
break;
|
||||
|
||||
case DeletionTarget.SpelunkKey:
|
||||
await DeleteSpelunkKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -11359,11 +11402,41 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
await ConnectToHostAsync(row, ConnectPassword, mayRememberPassword: true, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a terminal on a keychain host, with a password typed for it if it needs one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The body of <see cref="ConnectToSelectedHostAsync"/>, taking the host and the password as arguments so
|
||||
/// the desktop's terminal prompt can name a host without selecting it and type a password without writing
|
||||
/// it into <see cref="ConnectPassword"/> — which is the drawer's box, and would carry a remember-this
|
||||
/// choice the prompt never offered. Every refusal is still made here, once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The password is ignored for a host whose binding supplies its own credential, as the drawer's box is.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="row">The host to connect to.</param>
|
||||
/// <param name="password">The password typed for it, or empty.</param>
|
||||
/// <param name="mayRememberPassword">See <see cref="ConnectionTarget.MayRememberPassword"/>.</param>
|
||||
/// <param name="cancellationToken">Whatever the caller's own lifetime is.</param>
|
||||
internal async Task ConnectToHostAsync(
|
||||
HostRowViewModel row,
|
||||
string password,
|
||||
bool mayRememberPassword,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
// Refused rather than quietly falling back to the password box. A host set up for key-only access
|
||||
// that silently starts offering a password is the failure worth ruling out — the user asked for one
|
||||
// thing and got another, and the host is the last place that would say so.
|
||||
if (!TryBuildAuthentication(
|
||||
row.Host, row.Resolved, ConnectPassword, out var authentication, out var refusal))
|
||||
row.Host, row.Resolved, password, out var authentication, out var refusal))
|
||||
{
|
||||
Status = refusal;
|
||||
return;
|
||||
@@ -11376,13 +11449,89 @@ internal sealed partial class VaultViewModel(
|
||||
IsAskingForConnectPassword = false;
|
||||
|
||||
await ConnectToAsync(
|
||||
new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row),
|
||||
new ConnectionTarget(
|
||||
row.Label,
|
||||
row.Host.Hostname,
|
||||
row.Resolved.Port.Value,
|
||||
row,
|
||||
mayRememberPassword),
|
||||
authentication,
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a terminal on somewhere that is not in the keychain.
|
||||
/// Connects to the address typed into the hosts search box, with the password typed under it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log
|
||||
/// entry are the ones the connect prompt and the desktop palette produce, and nothing is written to the
|
||||
/// keychain.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The search box and the password are cleared before the dial, from copies taken first. The list left
|
||||
/// filtered to an address would be a list of nothing to come back to, and the password is a secret nobody
|
||||
/// asked to keep.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It takes no cancellation token and allows concurrent executions, for the two reasons
|
||||
/// <see cref="ConnectAsync"/> carries.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ConnectToFilterAddressAsync()
|
||||
{
|
||||
if (FilterAddress is not { } endpoint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (FilterAddressPassword.Length == 0)
|
||||
{
|
||||
FilterAddressRefusal = $"Type the password for {endpoint.Address}.";
|
||||
return;
|
||||
}
|
||||
|
||||
var password = FilterAddressPassword;
|
||||
|
||||
// In this order: clearing the filter clears the address and the password with it — see
|
||||
// OnHostFilterChanged — and the endpoint and password above are what is dialled.
|
||||
HostFilter = string.Empty;
|
||||
|
||||
await ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Reads the hosts search box as an address, if it has an <c>@</c> in it.</summary>
|
||||
private void RefreshFilterAddress(string filter)
|
||||
{
|
||||
var previous = FilterAddress;
|
||||
var trimmed = filter.Trim();
|
||||
|
||||
if (!trimmed.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
FilterAddress = null;
|
||||
FilterAddressRefusal = string.Empty;
|
||||
}
|
||||
else if (TryParseManualTarget(trimmed, out var endpoint, out var reason))
|
||||
{
|
||||
FilterAddress = endpoint;
|
||||
FilterAddressRefusal = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
FilterAddress = null;
|
||||
FilterAddressRefusal = reason;
|
||||
}
|
||||
|
||||
// A different machine, or none, is a different question: the password typed for the last one goes.
|
||||
if (FilterAddress != previous)
|
||||
{
|
||||
FilterAddressPassword = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a terminal on a typed address with a typed password, saving neither.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -11403,31 +11552,20 @@ internal sealed partial class VaultViewModel(
|
||||
/// a machine reached this way is exactly the one whose key nobody has seen before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It takes no cancellation token and allows concurrent executions, for the two reasons
|
||||
/// <see cref="ConnectAsync"/> carries.
|
||||
/// Every box an address is typed into — the connect prompt, the desktop's palette, the phone's hosts
|
||||
/// search — parses it with <see cref="TryParseManualTarget"/> and hands over what it read. The caller has
|
||||
/// already refused an unreadable address and an empty password, which is why neither is checked again:
|
||||
/// both refusals are sentences about a box, and only the caller knows which box.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None);
|
||||
|
||||
/// <inheritdoc cref="ConnectManuallyAsync()" />
|
||||
private async Task ConnectManuallyAsync(CancellationToken cancellationToken)
|
||||
internal Task ConnectToEndpointAsync(
|
||||
ManualEndpoint endpoint,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryParseManualTarget(ManualTarget, out var endpoint, out var refusal))
|
||||
{
|
||||
ManualStatus = refusal;
|
||||
return;
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(endpoint);
|
||||
|
||||
if (ManualPassword.Length == 0)
|
||||
{
|
||||
ManualStatus = "A password is needed. Save this machine as a host to reach it with a key.";
|
||||
return;
|
||||
}
|
||||
|
||||
ManualStatus = string.Empty;
|
||||
|
||||
await ConnectToAsync(
|
||||
return ConnectToAsync(
|
||||
// Labelled by what was typed rather than by the hostname alone. Two accounts on one box are two
|
||||
// different connections, and a strip showing the same name twice would be the tab equivalent of
|
||||
// the log entry this also names.
|
||||
@@ -11435,8 +11573,8 @@ internal sealed partial class VaultViewModel(
|
||||
$"{endpoint.Username}@{endpoint.Hostname}",
|
||||
endpoint.Hostname,
|
||||
endpoint.Port),
|
||||
new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)),
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
new HostAuthentication(endpoint.Username, new SshPasswordCredential(password)),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -11461,7 +11599,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// would make this the only field in the product with its own address grammar.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static bool TryParseManualTarget(
|
||||
internal static bool TryParseManualTarget(
|
||||
string typed,
|
||||
[NotNullWhen(true)] out ManualEndpoint? endpoint,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
@@ -11620,7 +11758,16 @@ internal sealed partial class VaultViewModel(
|
||||
/// credential rather than from the host — so a username on the shared record would be a field that is
|
||||
/// null for every connection but this one.
|
||||
/// </remarks>
|
||||
private sealed record ManualEndpoint(string Username, string Hostname, int Port);
|
||||
internal sealed record ManualEndpoint(string Username, string Hostname, int Port)
|
||||
{
|
||||
/// <summary>The address as it will be dialled, in the grammar it was typed in.</summary>
|
||||
/// <remarks>
|
||||
/// The same shape as <see cref="HostRowViewModel.Address"/> and the connection log's own entries, so
|
||||
/// the palette can compare what was typed against a saved host's address with a plain string match.
|
||||
/// </remarks>
|
||||
public string Address =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"{Username}@{Hostname}:{Port}");
|
||||
}
|
||||
|
||||
/// <summary>Everything both connect paths share, from the tab appearing to the session opening.</summary>
|
||||
/// <remarks>
|
||||
@@ -12122,10 +12269,10 @@ internal sealed partial class VaultViewModel(
|
||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||
// for must not wait on a vault write to appear.
|
||||
//
|
||||
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing
|
||||
// Only for a target that came from a keychain host. A machine typed in as an address has nothing
|
||||
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
|
||||
// screen it is typed on says so.
|
||||
if (target.Row is { } row)
|
||||
if (target is { Row: { } row, MayRememberPassword: true })
|
||||
{
|
||||
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
@@ -12412,6 +12559,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// <param name="Row">
|
||||
/// The keychain host this came from, or null for a machine that is not in the keychain.
|
||||
/// </param>
|
||||
/// <param name="MayRememberPassword">
|
||||
/// Whether <see cref="RemembersConnectPassword"/> applies to this attempt. That tick belongs to the
|
||||
/// drawer's password box, so only an attempt whose password came out of that box may act on it — a
|
||||
/// password typed into the desktop's terminal prompt, which offers no such choice, must not be saved
|
||||
/// because the tick happened to be left on in a drawer that is not even open.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
|
||||
@@ -12432,7 +12585,8 @@ internal sealed partial class VaultViewModel(
|
||||
string Label,
|
||||
string Hostname,
|
||||
int Port,
|
||||
HostRowViewModel? Row = null)
|
||||
HostRowViewModel? Row = null,
|
||||
bool MayRememberPassword = true)
|
||||
{
|
||||
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
|
||||
internal Guid? HostId => Row?.EntityId;
|
||||
@@ -12442,7 +12596,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
|
||||
/// reached with a key the item names, and a machine typed into the manual box has neither.
|
||||
/// reached with a key the item names, and a machine typed in as an address has neither.
|
||||
/// </remarks>
|
||||
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
|
||||
}
|
||||
@@ -13417,6 +13571,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
partial void OnHostFilterChanged(string value)
|
||||
{
|
||||
RefreshFilterAddress(value);
|
||||
RebuildVisibleHosts();
|
||||
RebuildHostSections();
|
||||
OnPropertyChanged(nameof(NoHostBoardMessage));
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<script src="/vendor/xterm.js"></script>
|
||||
<script src="/vendor/addon-fit.js"></script>
|
||||
<script src="/vendor/addon-webgl.js"></script>
|
||||
<script src="/vendor/addon-unicode11.js"></script>
|
||||
<script src="/terminal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -387,6 +387,28 @@ function createSession(sessionId) {
|
||||
|
||||
const fit = new FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
|
||||
/*
|
||||
── CHARACTER WIDTHS FROM UNICODE 11, NOT 6 ────────────────────────────────────────────────────────
|
||||
|
||||
How many columns a character takes is not something the terminal and the remote can each decide for
|
||||
themselves: the shell's line editor counts columns to know where the cursor is, and the terminal
|
||||
counts them to know where to draw. When the two disagree, every character after the one they
|
||||
disagree about lands a column away from where the remote believes it is — a prompt that redraws over
|
||||
itself, a cursor one cell off, a line that wraps early.
|
||||
|
||||
xterm.js ships with Unicode 6's table, which predates emoji as a wide class. So 😀 was one column
|
||||
here and two on any current remote (glibc, musl and every modern wcwidth follow Unicode 9 and later),
|
||||
and a line with an emoji in it drifted from the first emoji on. Measured against xterm.js 6.0
|
||||
headless: 😀 took one cell as shipped and two with this addon, and CJK, combining marks and every
|
||||
escape sequence behaved the same either way.
|
||||
|
||||
Before open() so the first frame is measured with the right table. activeVersion is a proposed API,
|
||||
which is what allowProposedApi above is already on for.
|
||||
*/
|
||||
term.loadAddon(new Unicode11Addon.Unicode11Addon());
|
||||
term.unicode.activeVersion = '11';
|
||||
|
||||
term.open(pane);
|
||||
|
||||
// WebGL where it is available. Falling back rather than failing matters because a software
|
||||
|
||||
+7
-4
@@ -12,18 +12,21 @@ All of the following are MIT licensed, the same licence as this repository.
|
||||
| `xterm.css` | `@xterm/xterm` | 6.0.0 | `https://unpkg.com/@xterm/xterm@6.0.0/css/xterm.css` |
|
||||
| `addon-fit.js` | `@xterm/addon-fit` | 0.11.0 | `https://unpkg.com/@xterm/addon-fit@0.11.0/lib/addon-fit.js` |
|
||||
| `addon-webgl.js` | `@xterm/addon-webgl` | 0.19.0 | `https://unpkg.com/@xterm/addon-webgl@0.19.0/lib/addon-webgl.js` |
|
||||
| `addon-unicode11.js` | `@xterm/addon-unicode11` | 0.9.0 | `https://unpkg.com/@xterm/addon-unicode11@0.9.0/lib/addon-unicode11.js` |
|
||||
|
||||
Copyright (c) 2017 The xterm.js authors. See <https://github.com/xtermjs/xterm.js> for the full
|
||||
licence text.
|
||||
|
||||
These are the UMD builds, so they attach to `globalThis`: `Terminal`, `FitAddon.FitAddon` and
|
||||
`WebglAddon.WebglAddon`. `terminal.js` depends on those exact names, so check them after any upgrade
|
||||
These are the UMD builds, so they attach to `globalThis`: `Terminal`, `FitAddon.FitAddon`,
|
||||
`WebglAddon.WebglAddon` and `Unicode11Addon.Unicode11Addon`. `terminal.js` depends on those exact names, so check them after any upgrade
|
||||
— a bundle that switched to ES modules would load without error and leave `Terminal` undefined.
|
||||
|
||||
## Upgrading
|
||||
|
||||
1. Download the four files at the new versions and update the table above.
|
||||
2. Confirm the UMD global names are unchanged.
|
||||
1. Download the five files at the new versions and update the table above.
|
||||
2. Confirm the UMD global names are unchanged, and that `term.unicode.activeVersion = '11'` still selects
|
||||
the addon's table: a version string xterm no longer recognises throws, and an addon that registered
|
||||
under another name would leave the page on Unicode 6 widths without a word.
|
||||
3. Confirm the right-click still belongs to `terminal.js`. Two things about xterm are load-bearing for it
|
||||
and neither is a global: `rightClickSelectsWord` has to still be an option — the page sets it false, and
|
||||
an option xterm no longer knows is silently ignored — and xterm has to still register its own right-click
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Unicode11Addon=t():e.Unicode11Addon=t()}(globalThis,(()=>(()=>{"use strict";var e={384:(e,t,s)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV11=void 0;const r=s(765),n=[[768,879],[1155,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1541],[1552,1562],[1564,1564],[1611,1631],[1648,1648],[1750,1757],[1759,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2045,2045],[2070,2073],[2075,2083],[2085,2087],[2089,2093],[2137,2139],[2259,2306],[2362,2362],[2364,2364],[2369,2376],[2381,2381],[2385,2391],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2558,2558],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2641,2641],[2672,2673],[2677,2677],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2810,2815],[2817,2817],[2876,2876],[2879,2879],[2881,2884],[2893,2893],[2902,2902],[2914,2915],[2946,2946],[3008,3008],[3021,3021],[3072,3072],[3076,3076],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3170,3171],[3201,3201],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3328,3329],[3387,3388],[3393,3396],[3405,3405],[3426,3427],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3981,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4151],[4153,4154],[4157,4158],[4184,4185],[4190,4192],[4209,4212],[4226,4226],[4229,4230],[4237,4237],[4253,4253],[4448,4607],[4957,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6158],[6277,6278],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6683,6683],[6742,6742],[6744,6750],[6752,6752],[6754,6754],[6757,6764],[6771,6780],[6783,6783],[6832,6846],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7040,7041],[7074,7077],[7080,7081],[7083,7085],[7142,7142],[7144,7145],[7149,7149],[7151,7153],[7212,7219],[7222,7223],[7376,7378],[7380,7392],[7394,7400],[7405,7405],[7412,7412],[7416,7417],[7616,7673],[7675,7679],[8203,8207],[8234,8238],[8288,8292],[8294,8303],[8400,8432],[11503,11505],[11647,11647],[11744,11775],[12330,12333],[12441,12442],[42607,42610],[42612,42621],[42654,42655],[42736,42737],[43010,43010],[43014,43014],[43019,43019],[43045,43046],[43204,43205],[43232,43249],[43263,43263],[43302,43309],[43335,43345],[43392,43394],[43443,43443],[43446,43449],[43452,43453],[43493,43493],[43561,43566],[43569,43570],[43573,43574],[43587,43587],[43596,43596],[43644,43644],[43696,43696],[43698,43700],[43703,43704],[43710,43711],[43713,43713],[43756,43757],[43766,43766],[44005,44005],[44008,44008],[44013,44013],[64286,64286],[65024,65039],[65056,65071],[65279,65279],[65529,65531]],i=[[66045,66045],[66272,66272],[66422,66426],[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[68325,68326],[68900,68903],[69446,69456],[69633,69633],[69688,69702],[69759,69761],[69811,69814],[69817,69818],[69821,69821],[69837,69837],[69888,69890],[69927,69931],[69933,69940],[70003,70003],[70016,70017],[70070,70078],[70089,70092],[70191,70193],[70196,70196],[70198,70199],[70206,70206],[70367,70367],[70371,70378],[70400,70401],[70459,70460],[70464,70464],[70502,70508],[70512,70516],[70712,70719],[70722,70724],[70726,70726],[70750,70750],[70835,70840],[70842,70842],[70847,70848],[70850,70851],[71090,71093],[71100,71101],[71103,71104],[71132,71133],[71219,71226],[71229,71229],[71231,71232],[71339,71339],[71341,71341],[71344,71349],[71351,71351],[71453,71455],[71458,71461],[71463,71467],[71727,71735],[71737,71738],[72148,72151],[72154,72155],[72160,72160],[72193,72202],[72243,72248],[72251,72254],[72263,72263],[72273,72278],[72281,72283],[72330,72342],[72344,72345],[72752,72758],[72760,72765],[72767,72767],[72850,72871],[72874,72880],[72882,72883],[72885,72886],[73009,73014],[73018,73018],[73020,73021],[73023,73029],[73031,73031],[73104,73105],[73109,73109],[73111,73111],[73459,73460],[78896,78904],[92912,92916],[92976,92982],[94031,94031],[94095,94098],[113821,113822],[113824,113827],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[121344,121398],[121403,121452],[121461,121461],[121476,121476],[121499,121503],[121505,121519],[122880,122886],[122888,122904],[122907,122913],[122915,122916],[122918,122922],[123184,123190],[123628,123631],[125136,125142],[125252,125258],[917505,917505],[917536,917631],[917760,917999]],o=[[4352,4447],[8986,8987],[9001,9002],[9193,9196],[9200,9200],[9203,9203],[9725,9726],[9748,9749],[9800,9811],[9855,9855],[9875,9875],[9889,9889],[9898,9899],[9917,9918],[9924,9925],[9934,9934],[9940,9940],[9962,9962],[9970,9971],[9973,9973],[9978,9978],[9981,9981],[9989,9989],[9994,9995],[10024,10Line truncated
|
||||
//# sourceMappingURL=addon-unicode11.js.map
|
||||
@@ -139,20 +139,41 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
IAsyncDisposable? lifetime,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var info = BuildConnectionInfo(request);
|
||||
|
||||
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
|
||||
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
|
||||
// since rebooted would otherwise wait on a socket nothing will ever answer.
|
||||
var client = new SftpClient(BuildConnectionInfo(request))
|
||||
//
|
||||
// ◆ The operation timeout is the connect timeout for the handshake, and nothing afterwards. SSH.NET's
|
||||
// default is to wait for ever, and its ConnectAsync runs the SFTP handshake — the version exchange
|
||||
// and the home directory lookup — synchronously after the login, where the cancellation token is not
|
||||
// read. A host whose login prints anything into the channel (an echo in .bashrc is the classic) sends
|
||||
// text SSH.NET reads as the length of a packet a gigabyte long, and the file browser then said
|
||||
// "Connecting…" for ever. Reset once connected, so a slow write on a slow link is not cut off.
|
||||
var client = new SftpClient(info)
|
||||
{
|
||||
BufferSize = SftpBufferSize,
|
||||
KeepAliveInterval = KeepAliveInterval,
|
||||
OperationTimeout = info.Timeout,
|
||||
};
|
||||
|
||||
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
|
||||
// reports itself, and a second connection opened behind an already-open shell has nothing the user
|
||||
// is watching a step list for.
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
HostKeyGate gate;
|
||||
|
||||
try
|
||||
{
|
||||
gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (SshOperationTimeoutException exception) when (info.IsAuthenticated)
|
||||
{
|
||||
throw SftpHandshakeTimedOut(request, info, exception);
|
||||
}
|
||||
|
||||
client.OperationTimeout = Timeout.InfiniteTimeSpan;
|
||||
|
||||
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
||||
// on first read, so leaving it to the property would put a round trip behind something that reads
|
||||
@@ -172,6 +193,24 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
return new SshNetSftpSession(client, gate.Presented!, home, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>Says what a timeout after the login means for a file-transfer connection.</summary>
|
||||
/// <remarks>
|
||||
/// Only after the login: a timeout before it is the network's or the server's, and SSH.NET's own words
|
||||
/// for it are left alone. One after it is the SFTP handshake's — the host let this account in and then
|
||||
/// never answered as a file server — and SSH.NET's "Operation has timed out" would send somebody looking
|
||||
/// at the network for what is nearly always a line of text in a login script on the host.
|
||||
/// </remarks>
|
||||
private static TimeoutException SftpHandshakeTimedOut(
|
||||
SshConnectionRequest request,
|
||||
ConnectionInfo info,
|
||||
SshOperationTimeoutException exception) =>
|
||||
new(
|
||||
$"{request.Host} accepted the login, but its SFTP service did not answer within "
|
||||
+ $"{info.Timeout.TotalSeconds:0} seconds. This is usually a login script on the host that "
|
||||
+ "prints text (an echo in .bashrc, say): SFTP needs the remote shell to stay silent when "
|
||||
+ "it is not interactive.",
|
||||
exception);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the handshake with host key trust attached, and translates a refusal this factory caused.
|
||||
/// </summary>
|
||||
|
||||
@@ -109,6 +109,29 @@ internal sealed class RememberedSignInRow
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>This device's sign-in to one Spelunk.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Everything sealed, the Spelunk's address included, in one record bound to its row: which Spelunk somebody
|
||||
/// manages their servers with is not for a stolen laptop to say. There are a handful at most, so finding the
|
||||
/// one for an address opens them all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per device, never synced: each device signs in through its own browser, and Spelunk lists and signs out
|
||||
/// each one on its own. Like <see cref="RememberedSignInRow"/>, sign-out deletes every row with the rest of
|
||||
/// the cache.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class SpelunkSignInRow
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>The sign-in, sealed under the LocalCacheKey. Ciphertext.</summary>
|
||||
public byte[] Sealed { get; set; } = [];
|
||||
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A vault the user can reach, with the grant that opens it.</summary>
|
||||
/// <remarks>
|
||||
/// Cached so the vault list and the key needed to decrypt it are both available offline. The name is
|
||||
|
||||
@@ -74,6 +74,7 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
||||
|
||||
ConfigureUnlockMaterial(modelBuilder);
|
||||
ConfigureRememberedSignIn(modelBuilder);
|
||||
ConfigureSpelunkSignIns(modelBuilder);
|
||||
ConfigureVaults(modelBuilder);
|
||||
ConfigureItems(modelBuilder);
|
||||
ConfigureOutbox(modelBuilder);
|
||||
@@ -123,6 +124,15 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
||||
entity.Property(row => row.SealedRefreshToken).IsRequired();
|
||||
});
|
||||
|
||||
private static void ConfigureSpelunkSignIns(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<SpelunkSignInRow>(entity =>
|
||||
{
|
||||
entity.ToTable("spelunk_sign_in");
|
||||
entity.HasKey(row => row.Id);
|
||||
entity.Property(row => row.Id).ValueGeneratedNever();
|
||||
entity.Property(row => row.Sealed).IsRequired();
|
||||
});
|
||||
|
||||
private static void ConfigureVaults(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<CachedVaultRow>(entity =>
|
||||
|
||||
@@ -141,7 +141,6 @@ internal static class AadResourceTypes
|
||||
SyncEntityType.ConnectionLogEntry => CryptoSpec.AadResourceType.ConnectionLogEntry,
|
||||
SyncEntityType.ActivityLogEntry => CryptoSpec.AadResourceType.ActivityLogEntry,
|
||||
SyncEntityType.ObjectStore => CryptoSpec.AadResourceType.ObjectStore,
|
||||
SyncEntityType.SpelunkKey => CryptoSpec.AadResourceType.SpelunkKey,
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(entityType), entityType, "No AAD resource type is defined for this entity type."),
|
||||
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DodoSSH.Client.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClientCacheContext))]
|
||||
[Migration("20261007152750_AddSpelunkSignIns")]
|
||||
partial class AddSpelunkSignIns
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<long>("ChangeSequence")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("change_sequence");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long?>("SweepMark")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sweep_mark");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("version");
|
||||
|
||||
b.Property<byte[]>("WrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_data_key");
|
||||
|
||||
b.HasKey("VaultId", "EntityType", "EntityId")
|
||||
.HasName("pk_item");
|
||||
|
||||
b.HasIndex("VaultId", "ChangeSequence")
|
||||
.HasDatabaseName("ix_item_vault_id_change_sequence");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType")
|
||||
.HasDatabaseName("ix_item_vault_id_entity_type");
|
||||
|
||||
b.ToTable("item", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<byte[]>("WrappedKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_key");
|
||||
|
||||
b.HasKey("VaultId", "KeyGeneration")
|
||||
.HasName("pk_vault_key_wrap");
|
||||
|
||||
b.ToTable("vault_key_wrap", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<bool>("Hidden")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("hidden");
|
||||
|
||||
b.Property<bool>("IsPersonal")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_personal");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("permissions");
|
||||
|
||||
b.Property<bool>("RekeyRequired")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("rekey_required");
|
||||
|
||||
b.Property<Guid?>("TeamId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("team_id");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<byte[]>("WrappedVaultKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_vault_key");
|
||||
|
||||
b.HasKey("VaultId")
|
||||
.HasName("pk_vault");
|
||||
|
||||
b.ToTable("vault", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Acknowledged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("acknowledged");
|
||||
|
||||
b.Property<byte[]>("Detail")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("detail");
|
||||
|
||||
b.Property<long>("DetectedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("detected_at_utc");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kind");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_conflict");
|
||||
|
||||
b.HasIndex("VaultId", "Acknowledged")
|
||||
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
|
||||
|
||||
b.ToTable("conflict", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<byte?>("AncestorAadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_aad_version");
|
||||
|
||||
b.Property<Guid?>("AncestorDataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ancestor_data_key_id");
|
||||
|
||||
b.Property<uint?>("AncestorKeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_key_generation");
|
||||
|
||||
b.Property<byte[]>("AncestorPayload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_payload");
|
||||
|
||||
b.Property<byte[]>("AncestorProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_protected_fields");
|
||||
|
||||
b.Property<int?>("AncestorVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_version");
|
||||
|
||||
b.Property<byte[]>("AncestorWrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_wrapped_data_key");
|
||||
|
||||
b.Property<int>("Attempts")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("attempts");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int?>("ExpectedVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("expected_version");
|
||||
|
||||
b.Property<bool>("IsParked")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_parked");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_error");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<Guid>("OperationId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("operation_id");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long>("QueuedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("queued_at_utc");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<byte[]>("WrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_data_key");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_outbox");
|
||||
|
||||
b.HasIndex("OperationId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_operation_id");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
|
||||
|
||||
b.HasIndex("VaultId", "IsParked", "Sequence")
|
||||
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
|
||||
|
||||
b.ToTable("outbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("SealedRefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed_refresh_token");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_remembered_sign_in");
|
||||
|
||||
b.ToTable("remembered_sign_in", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.SpelunkSignInRow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("Sealed")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_spelunk_sign_in");
|
||||
|
||||
b.ToTable("spelunk_sign_in", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<string>("Cursor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cursor");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<long?>("LastPulledAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pulled_at_utc");
|
||||
|
||||
b.Property<long?>("LastPushedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pushed_at_utc");
|
||||
|
||||
b.Property<string>("PulledTypes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pulled_types");
|
||||
|
||||
b.Property<long>("ServerTimeSkewMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("server_time_skew_ms");
|
||||
|
||||
b.Property<long?>("SweepMark")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sweep_mark");
|
||||
|
||||
b.Property<long>("SweepSeenUpTo")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sweep_seen_up_to");
|
||||
|
||||
b.HasKey("VaultId")
|
||||
.HasName("pk_sync_state");
|
||||
|
||||
b.ToTable("sync_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("DeviceId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("device_id");
|
||||
|
||||
b.Property<byte[]>("DeviceWrappedPrivateKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("device_wrapped_private_key");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("issuer");
|
||||
|
||||
b.Property<string>("KdfAlgorithm")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("kdf_algorithm");
|
||||
|
||||
b.Property<int>("KdfMemoryKibibytes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_memory_kibibytes");
|
||||
|
||||
b.Property<int>("KdfParallelism")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_parallelism");
|
||||
|
||||
b.Property<int>("KdfPasses")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_passes");
|
||||
|
||||
b.Property<byte[]>("KdfSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("kdf_salt");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("ServerUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("server_url");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<byte[]>("WrappedPrivateKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_private_key");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_unlock_material");
|
||||
|
||||
b.ToTable("unlock_material", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds this device's Spelunk sign-ins, and drops what is left of Spelunk keys (entity type 14), which
|
||||
/// they replaced: their cached items, their outbox entries and their conflicts.
|
||||
/// </summary>
|
||||
public partial class AddSpelunkSignIns : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DELETE FROM item WHERE entity_type = 14;");
|
||||
migrationBuilder.Sql("DELETE FROM outbox WHERE entity_type = 14;");
|
||||
migrationBuilder.Sql("DELETE FROM conflict WHERE entity_type = 14;");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "spelunk_sign_in",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
@sealed = table.Column<byte[]>(name: "sealed", type: "BLOB", nullable: false),
|
||||
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_spelunk_sign_in", x => x.id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "spelunk_sign_in");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,6 +344,27 @@ namespace DodoSSH.Client.Storage.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.SpelunkSignInRow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("Sealed")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_spelunk_sign_in");
|
||||
|
||||
b.ToTable("spelunk_sign_in", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DodoSSH.Crypto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>This device's sign-in to one Spelunk, as <see cref="SpelunkSignInStore"/> keeps it.</summary>
|
||||
/// <param name="ApiBase">The Spelunk API's base address, normalised as a host's route stores it.</param>
|
||||
/// <param name="Token">What Spelunk issued this device: sent as the bearer token on every ticket request.</param>
|
||||
/// <param name="SessionId">Spelunk's id for this sign-in, which its person sees under Your account.</param>
|
||||
/// <param name="Email">Who it is signed in as, for saying so.</param>
|
||||
/// <param name="DisplayName">The same, as Spelunk names them.</param>
|
||||
/// <param name="SignedInAtUtc">When this device signed in.</param>
|
||||
public sealed record SpelunkSignInRecord(
|
||||
string ApiBase,
|
||||
string Token,
|
||||
Guid SessionId,
|
||||
string Email,
|
||||
string DisplayName,
|
||||
DateTimeOffset SignedInAtUtc)
|
||||
{
|
||||
/// <summary>Keeps the token out of logs and debugger views.</summary>
|
||||
public override string ToString() => $"{Email} at {ApiBase}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Spelunks this device is signed in to, one sign-in each, sealed under the LocalCacheKey — see
|
||||
/// <see cref="SpelunkSignInRow"/> for why per device and why sealed whole.
|
||||
/// </summary>
|
||||
public sealed class SpelunkSignInStore(
|
||||
IDbContextFactory<ClientCacheContext> contexts,
|
||||
LocalCacheProtector protector,
|
||||
TimeProvider clock)
|
||||
{
|
||||
/// <summary>Every sign-in this key can open. One that will not open is skipped, as a stale cache record is.</summary>
|
||||
public async Task<IReadOnlyList<SpelunkSignInRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var rows = await context.Set<SpelunkSignInRow>()
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows
|
||||
.Select(Open)
|
||||
.OfType<SpelunkSignInRecord>()
|
||||
.OrderBy(record => record.ApiBase, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>The sign-in for a Spelunk, or null when this device is not signed in to it.</summary>
|
||||
public async Task<SpelunkSignInRecord?> FindAsync(string apiBase, CancellationToken cancellationToken) =>
|
||||
(await ListAsync(cancellationToken).ConfigureAwait(false))
|
||||
.FirstOrDefault(record => string.Equals(record.ApiBase, apiBase, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>Keeps a sign-in, replacing any this device had for the same Spelunk.</summary>
|
||||
public async Task SaveAsync(SpelunkSignInRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var rows = await context.Set<SpelunkSignInRow>().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
context.RemoveRange(rows.Where(row => Open(row) is not { } open || string.Equals(open.ApiBase, record.ApiBase, StringComparison.Ordinal)));
|
||||
|
||||
var id = Guid.CreateVersion7();
|
||||
var bytes = Serialize(record);
|
||||
try
|
||||
{
|
||||
context.Add(new SpelunkSignInRow
|
||||
{
|
||||
Id = id,
|
||||
Sealed = protector.Protect(CryptoSpec.AadResourceType.SpelunkSignIn, id, bytes),
|
||||
UpdatedAtUtc = clock.GetUtcNow(),
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Forgets the sign-in for a Spelunk, if this device had one.</summary>
|
||||
public async Task ForgetAsync(string apiBase, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var rows = await context.Set<SpelunkSignInRow>().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
context.RemoveRange(rows.Where(row => Open(row) is not { } open || string.Equals(open.ApiBase, apiBase, StringComparison.Ordinal)));
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Forgets every Spelunk sign-in on this device.</summary>
|
||||
public async Task ForgetAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
await context.Set<SpelunkSignInRow>().ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private SpelunkSignInRecord? Open(SpelunkSignInRow row)
|
||||
{
|
||||
var opened = protector.TryUnprotect(CryptoSpec.AadResourceType.SpelunkSignIn, row.Id, row.Sealed);
|
||||
if (opened is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Deserialize(opened);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(opened);
|
||||
}
|
||||
}
|
||||
|
||||
// Written by hand rather than through a serializer context: six fields, and nothing here should depend
|
||||
// on reflection the Android build trims away.
|
||||
private static byte[] Serialize(SpelunkSignInRecord record)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(buffer))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("api", record.ApiBase);
|
||||
writer.WriteString("token", record.Token);
|
||||
writer.WriteString("session", record.SessionId);
|
||||
writer.WriteString("email", record.Email);
|
||||
writer.WriteString("name", record.DisplayName);
|
||||
writer.WriteString("at", record.SignedInAtUtc);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static SpelunkSignInRecord? Deserialize(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(bytes);
|
||||
var root = document.RootElement;
|
||||
return new SpelunkSignInRecord(
|
||||
root.GetProperty("api").GetString()!,
|
||||
root.GetProperty("token").GetString()!,
|
||||
root.GetProperty("session").GetGuid(),
|
||||
root.GetProperty("email").GetString()!,
|
||||
root.GetProperty("name").GetString()!,
|
||||
root.GetProperty("at").GetDateTimeOffset());
|
||||
}
|
||||
catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException or FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,9 +170,6 @@ internal static class ItemKinds
|
||||
|
||||
(SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) =>
|
||||
new ItemReconciler<ObjectStoreSecret>(ObjectStoreKind.Instance, outbox, conflicts, keyring)),
|
||||
|
||||
(SyncEntityType.SpelunkKey, static (outbox, conflicts, keyring) =>
|
||||
new ItemReconciler<SpelunkKeySecret>(SpelunkKeyKind.Instance, outbox, conflicts, keyring)),
|
||||
];
|
||||
|
||||
/// <summary>The types to ask the server for, in a fixed order.</summary>
|
||||
@@ -1061,82 +1058,3 @@ internal sealed class ObjectStoreKind : IItemKind<ObjectStoreSecret>
|
||||
return secret with { Label = label };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Spelunk keys.</summary>
|
||||
internal sealed class SpelunkKeyKind : IItemKind<SpelunkKeySecret>
|
||||
{
|
||||
internal static SpelunkKeyKind Instance { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType EntityType => SyncEntityType.SpelunkKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Noun => "Spelunk key";
|
||||
|
||||
/// <inheritdoc />
|
||||
public OpenedItem<SpelunkKeySecret>? TryOpen(
|
||||
EncryptedPayload payload,
|
||||
ReadOnlySpan<byte> vaultKey,
|
||||
Guid entityId,
|
||||
int itemVersion)
|
||||
{
|
||||
var document = SpelunkKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
|
||||
|
||||
return document is null
|
||||
? null
|
||||
: new OpenedItem<SpelunkKeySecret>(document.Key, document.IsReadOnly);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public EncryptedPayload Seal(
|
||||
SpelunkKeySecret secret,
|
||||
ReadOnlySpan<byte> vaultKey,
|
||||
Guid entityId,
|
||||
uint keyGeneration,
|
||||
int itemVersion) =>
|
||||
SpelunkKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Nothing. The address is the only candidate and must not go: nothing on the server calls Spelunk.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Fields(SpelunkKeySecret secret) => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public MergedItem<SpelunkKeySecret> Merge(
|
||||
SpelunkKeySecret ancestor,
|
||||
SpelunkKeySecret local,
|
||||
SpelunkKeySecret remote)
|
||||
{
|
||||
var merged = SpelunkKeySecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
return new MergedItem<SpelunkKeySecret>(merged.Merged, merged.Conflicts);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The key is compared and never reported by value, which is the rule every credential-like field in this
|
||||
/// file follows. The address is shown: it is where the key goes, and a change to it is worth reading.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Changes(SpelunkKeySecret before, SpelunkKeySecret after)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(before);
|
||||
ArgumentNullException.ThrowIfNull(after);
|
||||
|
||||
var changed = new List<string>();
|
||||
|
||||
Note(changed, "Name", before.Label, after.Label);
|
||||
Note(changed, "Spelunk address", before.ApiBase, after.ApiBase);
|
||||
Note(changed, "API key", before.ApiKey, after.ApiKey);
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SpelunkKeySecret Relabel(SpelunkKeySecret secret, string label)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(secret);
|
||||
|
||||
return secret with { Label = label };
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a Spelunk key into an item payload and back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Mirrors <see cref="ObjectStoreCipher"/> exactly, including the version rule in <see cref="SyncVersions"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The resource type is named, never cast.</b> <c>SyncEntityType.SpelunkKey</c> is 14 and
|
||||
/// <c>CryptoSpec.AadResourceType.SpelunkKey</c> is 17; 14 in the crypto enum is a connection log entry, and a
|
||||
/// cast would seal an API key under that — which decrypts perfectly on the machine that wrote it and violates
|
||||
/// docs/crypto.md everywhere else.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SpelunkKeyCipher
|
||||
{
|
||||
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.SpelunkKey;
|
||||
|
||||
/// <summary>Encrypts a Spelunk key.</summary>
|
||||
/// <param name="key">The key. Must be valid for storage.</param>
|
||||
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
|
||||
/// <param name="entityId">The item id, which the AAD binds.</param>
|
||||
/// <param name="keyGeneration">The vault's current key generation.</param>
|
||||
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
|
||||
public static EncryptedPayload Seal(
|
||||
SpelunkKeySecret key,
|
||||
ReadOnlySpan<byte> vaultKey,
|
||||
Guid entityId,
|
||||
uint keyGeneration,
|
||||
int itemVersion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
|
||||
|
||||
var plaintext = SpelunkKeySecretCodec.Encode(key);
|
||||
var dataKey = ItemKeys.CreateDataKey();
|
||||
|
||||
try
|
||||
{
|
||||
var dataKeyId = Guid.CreateVersion7();
|
||||
|
||||
var wrappedDataKey = ItemKeys.WrapDataKey(
|
||||
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
|
||||
|
||||
var envelope = ItemKeys.SealPayload(
|
||||
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
|
||||
|
||||
return new EncryptedPayload(
|
||||
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(dataKey);
|
||||
|
||||
// It holds an API key, which is live on the Spelunk it belongs to.
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Decrypts a Spelunk key.</summary>
|
||||
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
|
||||
public static SpelunkKeySecretDocument? TryOpen(
|
||||
EncryptedPayload payload,
|
||||
ReadOnlySpan<byte> vaultKey,
|
||||
Guid entityId,
|
||||
int itemVersion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dataKey = ItemKeys.TryUnwrapDataKey(
|
||||
vaultKey,
|
||||
payload.WrappedDataKey,
|
||||
Resource,
|
||||
entityId,
|
||||
payload.KeyGeneration,
|
||||
(uint)itemVersion);
|
||||
|
||||
if (dataKey is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var plaintext = ItemKeys.TryOpenPayload(
|
||||
dataKey,
|
||||
payload.Envelope,
|
||||
Resource,
|
||||
entityId,
|
||||
payload.DataKeyId,
|
||||
payload.KeyGeneration,
|
||||
(uint)itemVersion);
|
||||
|
||||
if (plaintext is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return SpelunkKeySecretCodec.TryDecode(plaintext, out var document) ? document : null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(dataKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Storage;
|
||||
|
||||
namespace DodoSSH.Client.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// The Spelunk keys in this vault, decrypted, with unpushed local changes laid over them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Another facade over the same generic repository. A key is read when the vault is listed and again at
|
||||
/// connect, from the list, like a credential's password.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkKeyRepository(
|
||||
ItemStore items,
|
||||
OutboxStore outbox,
|
||||
VaultKeyring keyring,
|
||||
IActivityLogSink? activity = null)
|
||||
{
|
||||
private readonly VaultItemRepository<SpelunkKeySecret> keys =
|
||||
new(SpelunkKeyKind.Instance, items, outbox, keyring, activity);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
|
||||
public Task<ItemListing<SpelunkKeySecret>> ListAsync(
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken) =>
|
||||
keys.ListAsync(vaultId, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
|
||||
public Task<Guid> CreateAsync(
|
||||
Guid vaultId,
|
||||
SpelunkKeySecret key,
|
||||
CancellationToken cancellationToken) =>
|
||||
keys.CreateAsync(vaultId, key, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.UpdateAsync" />
|
||||
public Task UpdateAsync(
|
||||
Guid vaultId,
|
||||
Guid entityId,
|
||||
SpelunkKeySecret key,
|
||||
CancellationToken cancellationToken) =>
|
||||
keys.UpdateAsync(vaultId, entityId, key, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
|
||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||
keys.DeleteAsync(vaultId, entityId, cancellationToken);
|
||||
}
|
||||
@@ -219,7 +219,7 @@ public sealed class SyncEngine
|
||||
/// <summary>Where the pull should carry on from, which is the start when the types it reads have changed.</summary>
|
||||
/// <remarks>
|
||||
/// A cursor read under a different list of types is past changes of the types it did not ask for, so an
|
||||
/// upgrade that adds one — SpelunkKey, ObjectStore — would never receive the items of that type made
|
||||
/// upgrade that adds one — ObjectStore, say — would never receive the items of that type made
|
||||
/// before it. Starting over once is the only way to see them; applying is idempotent, which is what makes
|
||||
/// the restart for a refused cursor safe as well. A state from before the list was kept has none and
|
||||
/// starts over once for the same reason: it may already have missed some.
|
||||
|
||||
@@ -471,7 +471,6 @@ DodoSSH.Contracts.SyncEntityType.KnownHostKey = 10 -> DodoSSH.Contracts.SyncEnti
|
||||
DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.PortForward = 9 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.Snippet = 8 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.SpelunkKey = 14 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.SshKey = 3 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.Tag = 5 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.Unspecified = 0 -> DodoSSH.Contracts.SyncEntityType
|
||||
|
||||
@@ -67,13 +67,6 @@ public enum SyncEntityType
|
||||
/// </remarks>
|
||||
ObjectStore = 13,
|
||||
|
||||
/// <summary>
|
||||
/// A Spelunk API key, and the one Spelunk address it may be sent to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An item of its own rather than a credential's password, because the address is the point: a link from
|
||||
/// Spelunk can be sent by anything on the machine, so a key may only go where the user said it goes, and
|
||||
/// only an item that carries both can say so. See <c>SpelunkKeySecret</c>.
|
||||
/// </remarks>
|
||||
SpelunkKey = 14,
|
||||
// 14 was SpelunkKey, a Spelunk API key and its address, removed while still in beta when signing in to
|
||||
// Spelunk replaced it. Not to be reused: an old client may still send it, and must be refused as unknown.
|
||||
}
|
||||
@@ -174,8 +174,11 @@ public static class CryptoSpec
|
||||
/// <summary>An S3-compatible bucket, and the credentials that reach it.</summary>
|
||||
ObjectStore = 16,
|
||||
|
||||
/// <summary>A Spelunk API key, and the one Spelunk address it may be sent to.</summary>
|
||||
SpelunkKey = 17,
|
||||
// 17 was SpelunkKey, removed with its item type (SyncEntityType 14). Not to be reused, so no payload
|
||||
// sealed under it can ever open as something else.
|
||||
|
||||
/// <summary>This device's sign-in to one Spelunk. Local cache only; never synced.</summary>
|
||||
SpelunkSignIn = 18,
|
||||
}
|
||||
|
||||
/// <summary>HKDF info labels. Domain-separated so one subkey cannot stand in for another.</summary>
|
||||
|
||||
@@ -556,62 +556,6 @@ public sealed class VaultObjectStore : IVaultItem
|
||||
public Guid UpdatedByUserId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Spelunk API key and the Spelunk address it may be sent to, as ciphertext.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="VaultObjectStore"/>'s shape, for the same reason: the key is a password, and the address beside
|
||||
/// it is where the user's servers are managed. Nothing on the server calls Spelunk, so there is no plaintext
|
||||
/// column and no argument for one.
|
||||
/// </remarks>
|
||||
public sealed class VaultSpelunkKey : IVaultItem
|
||||
{
|
||||
/// <summary>Primary key. UUIDv7, generated by the client so a key can be added offline.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Owning vault.</summary>
|
||||
public Guid VaultId { get; set; }
|
||||
|
||||
/// <summary>Owning vault.</summary>
|
||||
public Vault? Vault { get; set; }
|
||||
|
||||
/// <summary>The encrypted key and its address: a DSH1 envelope. Opaque to the server.</summary>
|
||||
public byte[] Payload { get; set; } = [];
|
||||
|
||||
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
|
||||
public byte[]? DataKeyWrap { get; set; }
|
||||
|
||||
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
|
||||
public Guid? ContentKeyId { get; set; }
|
||||
|
||||
/// <summary>Vault key generation this payload was encrypted under.</summary>
|
||||
public int KeyGeneration { get; set; }
|
||||
|
||||
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
|
||||
public short PayloadAadVersion { get; set; }
|
||||
|
||||
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
|
||||
public int Version { get; set; }
|
||||
|
||||
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
|
||||
public long ChangeSequence { get; set; }
|
||||
|
||||
/// <summary>Creation timestamp.</summary>
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Last modification timestamp.</summary>
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Soft-delete marker; a tombstone, so an offline client learns the key went away.</summary>
|
||||
public DateTimeOffset? DeletedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Who created it.</summary>
|
||||
public Guid CreatedByUserId { get; set; }
|
||||
|
||||
/// <summary>Who last modified it.</summary>
|
||||
public Guid UpdatedByUserId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One connection that was made, as ciphertext.
|
||||
/// </summary>
|
||||
|
||||
@@ -58,8 +58,7 @@ public enum ChangeEntityType
|
||||
/// <summary>An S3-compatible bucket, and the credentials that reach it. M2.</summary>
|
||||
ObjectStore = 13,
|
||||
|
||||
/// <summary>A Spelunk API key, and the one Spelunk address it may be sent to.</summary>
|
||||
SpelunkKey = 14,
|
||||
// 14 was SpelunkKey, removed with its table; see the RemoveSpelunkKeyItem migration. Not to be reused.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -384,41 +384,6 @@ public sealed class ObjectStoreConfiguration : IEntityTypeConfiguration<VaultObj
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps <see cref="VaultSpelunkKey"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ObjectStoreConfiguration"/> again: a key and the address it goes to, neither of which belongs
|
||||
/// in a column.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkKeyConfiguration : IEntityTypeConfiguration<VaultSpelunkKey>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<VaultSpelunkKey> builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.ToTable("spelunk_key");
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
// Client-generated UUIDv7: a key must be addable offline, with its id.
|
||||
builder.Property(s => s.Id).ValueGeneratedNever();
|
||||
builder.UseXminConcurrencyToken();
|
||||
|
||||
builder.Property(s => s.Payload).IsRequired();
|
||||
|
||||
builder.HasIndex(s => new { s.VaultId, s.ChangeSequence });
|
||||
|
||||
builder.HasIndex(s => s.VaultId)
|
||||
.HasFilter("deleted_at_utc IS NULL")
|
||||
.HasDatabaseName("ix_spelunk_key_vault_live");
|
||||
|
||||
builder.ToTable(t => t.HasCheckConstraint(
|
||||
"ck_spelunk_key_version",
|
||||
"version >= 1"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps <see cref="VaultChange"/>.</summary>
|
||||
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
|
||||
{
|
||||
|
||||
@@ -81,9 +81,6 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
|
||||
/// <summary>S3-compatible buckets and their credentials, held as ciphertext.</summary>
|
||||
public DbSet<VaultObjectStore> ObjectStores => Set<VaultObjectStore>();
|
||||
|
||||
/// <summary>Spelunk API keys and the addresses they may be sent to, held as ciphertext.</summary>
|
||||
public DbSet<VaultSpelunkKey> SpelunkKeys => Set<VaultSpelunkKey>();
|
||||
|
||||
/// <summary>The per-vault change log that delta sync reads.</summary>
|
||||
public DbSet<VaultChange> VaultChanges => Set<VaultChange>();
|
||||
|
||||
|
||||
+1911
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Infrastructure.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Removes Spelunk keys, the API keys DodoSSH sent to Spelunk: each device now signs in to Spelunk and
|
||||
/// keeps that sign-in to itself, so there is nothing of Spelunk's left to sync.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Destructive, deliberately: still in beta, and a key has no use without the code that sent it. Their
|
||||
/// change-log entries go too (entity type 14), so no client is ever handed a change of a type it no longer
|
||||
/// knows. Down recreates the empty table and nothing in it.
|
||||
/// </remarks>
|
||||
public partial class RemoveSpelunkKeyItem : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DELETE FROM dodo.sync_change WHERE entity_type = 14;");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "spelunk_key",
|
||||
schema: "dodo");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "spelunk_key",
|
||||
schema: "dodo",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
vault_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
change_sequence = table.Column<long>(type: "bigint", nullable: false),
|
||||
content_key_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
created_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
data_key_wrap = table.Column<byte[]>(type: "bytea", nullable: true),
|
||||
deleted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
key_generation = table.Column<int>(type: "integer", nullable: false),
|
||||
payload = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
payload_aad_version = table.Column<short>(type: "smallint", nullable: false),
|
||||
updated_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
updated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
version = table.Column<int>(type: "integer", nullable: false),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_spelunk_key", x => x.id);
|
||||
table.CheckConstraint("ck_spelunk_key_version", "version >= 1");
|
||||
table.ForeignKey(
|
||||
name: "fk_spelunk_key_vaults_vault_id",
|
||||
column: x => x.vault_id,
|
||||
principalSchema: "dodo",
|
||||
principalTable: "vault",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_spelunk_key_vault_id_change_sequence",
|
||||
schema: "dodo",
|
||||
table: "spelunk_key",
|
||||
columns: new[] { "vault_id", "change_sequence" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_spelunk_key_vault_live",
|
||||
schema: "dodo",
|
||||
table: "spelunk_key",
|
||||
column: "vault_id",
|
||||
filter: "deleted_at_utc IS NULL");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1491,87 +1491,6 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ChangeSequence")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("change_sequence");
|
||||
|
||||
b.Property<Guid?>("ContentKeyId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("content_key_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at_utc");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<byte[]>("DataKeyWrap")
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("data_key_wrap");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at_utc");
|
||||
|
||||
b.Property<int>("KeyGeneration")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<short>("PayloadAadVersion")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("payload_aad_version");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<Guid>("UpdatedByUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("updated_by_user_id");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("version");
|
||||
|
||||
b.Property<uint>("xmin")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_spelunk_key");
|
||||
|
||||
b.HasIndex("VaultId")
|
||||
.HasDatabaseName("ix_spelunk_key_vault_live")
|
||||
.HasFilter("deleted_at_utc IS NULL");
|
||||
|
||||
b.HasIndex("VaultId", "ChangeSequence")
|
||||
.HasDatabaseName("ix_spelunk_key_vault_id_change_sequence");
|
||||
|
||||
b.ToTable("spelunk_key", "dodo", t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_spelunk_key_version", "version >= 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1939,18 +1858,6 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
b.Navigation("Vault");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", b =>
|
||||
{
|
||||
b.HasOne("DodoSSH.Domain.Vault", "Vault")
|
||||
.WithMany()
|
||||
.HasForeignKey("VaultId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_spelunk_key_vaults_vault_id");
|
||||
|
||||
b.Navigation("Vault");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
|
||||
{
|
||||
b.HasOne("DodoSSH.Domain.Vault", "Vault")
|
||||
|
||||
@@ -1186,52 +1186,21 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkKey_RoundTripsAsCiphertextWithNoPlaintextAtAll()
|
||||
public async Task ASpelunkKeyFromAnOlderClient_IsRefused_AsATypeThisServerDoesNotSync()
|
||||
{
|
||||
// 14 was SpelunkKey, removed when DodoSSH started signing in to Spelunk. An older client may still push
|
||||
// one; it is told so for that item, and nothing of it is kept.
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
|
||||
var keyId = Guid.CreateVersion7();
|
||||
|
||||
var pushed = await client.PostContractAsync(
|
||||
PushUrl(vaultId),
|
||||
new SyncPushRequest(
|
||||
[SpelunkKeyOperation(keyId, expectedVersion: null, envelope: [4, 2])]));
|
||||
new SyncPushRequest([RetiredSpelunkKeyOperation(Guid.CreateVersion7(), [4, 2])]));
|
||||
|
||||
var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
|
||||
results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);
|
||||
|
||||
var pulled = await client.PostContractAsync(
|
||||
PullUrl(vaultId),
|
||||
new SyncPullRequest(null, null, [SyncEntityType.SpelunkKey]));
|
||||
|
||||
var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
|
||||
var change = page!.Changes.ShouldHaveSingleItem();
|
||||
|
||||
change.EntityType.ShouldBe(SyncEntityType.SpelunkKey);
|
||||
change.EntityId.ShouldBe(keyId);
|
||||
change.Payload.ShouldNotBeNull().Envelope.ShouldBe([4, 2]);
|
||||
|
||||
change.PlaintextFields.ShouldBeNull("where somebody manages their servers from is not this server's to keep");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkKeyCarryingItsAddressInTheClear_IsRejected()
|
||||
{
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
|
||||
var operation = SpelunkKeyOperation(Guid.CreateVersion7(), null, [1])
|
||||
with
|
||||
{ PlaintextFields = new SyncPlaintextFields(RelayEnabled: false, Hostname: "api.spelunk.example.com", Port: 443) };
|
||||
|
||||
var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));
|
||||
|
||||
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
|
||||
.ShouldHaveSingleItem();
|
||||
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results.ShouldHaveSingleItem();
|
||||
|
||||
result.Status.ShouldBe(SyncOperationStatus.Invalid);
|
||||
result.Detail.ShouldNotBeNull().ShouldContain("stays encrypted");
|
||||
result.Detail.ShouldNotBeNull().ShouldContain("not yet supported");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1724,16 +1693,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
Payload(envelope),
|
||||
PlaintextFields: null);
|
||||
|
||||
private static SyncPushOperation SpelunkKeyOperation(
|
||||
Guid entityId,
|
||||
int? expectedVersion,
|
||||
byte[] envelope) =>
|
||||
private static SyncPushOperation RetiredSpelunkKeyOperation(Guid entityId, byte[] envelope) =>
|
||||
new(
|
||||
Guid.CreateVersion7(),
|
||||
SyncEntityType.SpelunkKey,
|
||||
(SyncEntityType)14,
|
||||
entityId,
|
||||
SyncOperation.Upsert,
|
||||
expectedVersion,
|
||||
ExpectedVersion: null,
|
||||
Payload(envelope),
|
||||
PlaintextFields: null);
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// How the empty terminal's prompt answers a keyboard: where the caret goes, and what Enter, Tab and Escape do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What each key decides is <c>ShellFlowTests</c>' business; this is the half only a real control can show —
|
||||
/// that the caret is on the line that is asking, every time the line that is asking changes. A prompt whose
|
||||
/// password line appeared without the caret on it would take the password into the name it had just read.
|
||||
/// The fixture is the palette suite's, for the palette suite's reasons: a real shell over a real unlocked
|
||||
/// vault, and nothing that reaches a network.
|
||||
/// </remarks>
|
||||
public sealed class ConnectPromptTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
private const string ServerUrl = "https://dodossh.example";
|
||||
|
||||
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeAccountServer server = new();
|
||||
private readonly StubKeyBinding keyBinding = new();
|
||||
private readonly VaultKnownHostStore knownHosts = new();
|
||||
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultSession session = null!;
|
||||
private VaultViewModel vault = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
caches = ClientCacheFactory.ForMemory($"prompt-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
||||
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||
session = outcome.Session!;
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
await knownHosts.OpenAsync(session, Token);
|
||||
|
||||
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||
|
||||
await SeedAsync();
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
ClientPaths.Default,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
Substitute.For<IDeviceKeyStore>(),
|
||||
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
||||
TimeProvider.System,
|
||||
// Never asked for a session: the palette searches the host list and connects through the
|
||||
// vault's own command, and nothing on this screen transfers a file.
|
||||
Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile)
|
||||
{
|
||||
// The state the palette is only ever open in. Assigned rather than reached through the unlock
|
||||
// path, which would be a second enrollment and a second Argon2 pass for no extra coverage.
|
||||
State = ShellState.Unlocked,
|
||||
Vault = vault,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
await session.DisposeAsync();
|
||||
caches.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePromptTakesTheKeyboardWhenItAppears()
|
||||
{
|
||||
await OnThePromptAsync((prompt, _) => prompt.TargetBox.IsFocused.ShouldBeTrue());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Enter on an address moves the question to the password, and the caret with it — typing straight on is
|
||||
/// what somebody does next, and it has to land in the password box rather than on the end of the address.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EnterOnAnAddressPutsTheCaretOnThePassword()
|
||||
{
|
||||
await OnThePromptAsync((prompt, window) =>
|
||||
{
|
||||
shell.PromptText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||
prompt.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
|
||||
prompt.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>Escape off the password line puts the caret back on the name, which is still there.</remarks>
|
||||
[Fact]
|
||||
public async Task EscapeFromThePasswordPutsTheCaretBackOnTheName()
|
||||
{
|
||||
await OnThePromptAsync((prompt, window) =>
|
||||
{
|
||||
shell.PromptText = "root@10.0.0.5";
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||
prompt.PasswordBox.IsEffectivelyVisible.ShouldBeFalse();
|
||||
prompt.TargetBox.IsFocused.ShouldBeTrue();
|
||||
shell.PromptText.ShouldBe("root@10.0.0.5");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Tab completes rather than moving the focus away, when there is something to complete to — and leaves
|
||||
/// the caret at the end of what it wrote, so Enter is the next key rather than End.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TabCompletesTheNameAndKeepsTheCaretOnIt()
|
||||
{
|
||||
await OnThePromptAsync((prompt, window) =>
|
||||
{
|
||||
shell.PromptText = "host-3";
|
||||
shell.PromptMatches.ShouldNotBeEmpty();
|
||||
|
||||
shell.PromptText = "ost-3";
|
||||
window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None);
|
||||
|
||||
shell.PromptText.ShouldBe("host-3");
|
||||
prompt.TargetBox.IsFocused.ShouldBeTrue("Tab completed rather than moving on");
|
||||
prompt.TargetBox.CaretIndex.ShouldBe("host-3".Length);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>Shows the prompt in a window the size of the terminal's rectangle, and runs one body against it.</summary>
|
||||
private Task OnThePromptAsync(Action<ConnectPrompt, Window> body) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.IsConnectPromptShowing.ShouldBeTrue("every case here starts on an empty terminal");
|
||||
|
||||
var prompt = new ConnectPrompt { DataContext = shell };
|
||||
var window = new Window { Content = prompt };
|
||||
LayoutHarness.Settle(window, 900, 600);
|
||||
|
||||
// The posted focus the application's dispatcher would run once the prompt was laid out.
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
try
|
||||
{
|
||||
body(prompt, window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Runs the layout pass, and the focus it posts, that the application's dispatcher would.</summary>
|
||||
private static void Relayout(Window window)
|
||||
{
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
}
|
||||
|
||||
/// <remarks>Enough hosts that a partial name has more than one to match.</remarks>
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = $"host-{i}";
|
||||
vault.EditorHostname = $"host-{i}.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -321,6 +321,263 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
Token);
|
||||
}
|
||||
|
||||
// ---- A machine that is not saved ----
|
||||
|
||||
/// <remarks>
|
||||
/// The palette's answer to an address somebody has just been given: the query reads as one, a row offers
|
||||
/// to dial it, and that row is the one Enter takes — nothing saved answers an address nobody has saved.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TypingAnAddressOffersToConnectWithoutSavingIt()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "root@10.0.0.5:2222";
|
||||
Relayout(window);
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeTrue();
|
||||
shell.IsAdHocSelected.ShouldBeTrue("the typed address is what the query is plainly about");
|
||||
shell.SelectedSearchResult.ShouldBeNull("exactly one row is lit");
|
||||
|
||||
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeTrue();
|
||||
VisibleTexts(palette.AdHocTargetRow).ShouldContain("root@10.0.0.5:2222", StringComparer.Ordinal);
|
||||
VisibleTexts(palette.AdHocTargetRow).ShouldContain("not saved", StringComparer.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The default port is printed rather than implied, because it is what will be dialled — and it is what
|
||||
/// makes the row comparable with a saved host's address, which always carries one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressWithNoPortIsOfferedOnTwentyTwo()
|
||||
{
|
||||
await OnThePaletteAsync((_, _) =>
|
||||
{
|
||||
shell.SearchText = "root@box";
|
||||
|
||||
shell.AdHocAddress.ShouldBe("root@box:22");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A query with no <c>@</c> is a search and nothing else, so a name that matches no host offers no row to
|
||||
/// dial — but it does say what the box would take instead, because a name that matches nothing is often a
|
||||
/// machine that was never saved.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ANameThatMatchesNothingSaysHowToReachAnUnsavedMachine()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "nothing-is-called-this";
|
||||
Relayout(window);
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeFalse();
|
||||
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeFalse();
|
||||
VisibleTexts(palette).ShouldContain(
|
||||
text => text.Contains("user@host", StringComparison.Ordinal),
|
||||
"the hint names the other thing the box takes");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A query that looks like an address and cannot be one says why under the box, rather than producing no
|
||||
/// row and no reason — which would read as a palette that ignored the one thing it was asked.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressThatCannotBeReadSaysWhy()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "root@box:99999";
|
||||
Relayout(window);
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeFalse();
|
||||
VisibleTexts(palette).ShouldContain(
|
||||
text => text.Contains("port", StringComparison.Ordinal),
|
||||
"the refusal is the manual parser's own sentence");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The password is asked for in the palette itself, and the caret goes with the question: typing an
|
||||
/// address and pressing Enter has to leave somebody typing a password, not hunting for the box.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EnterOnTheAddressAsksForItsPasswordInThePalette()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsSearching.ShouldBeTrue("nothing has been dialled yet");
|
||||
shell.IsAskingForAdHocPassword.ShouldBeTrue();
|
||||
palette.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
|
||||
palette.QueryBox.IsEffectivelyVisible.ShouldBeFalse();
|
||||
palette.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Escape from the password goes back to the address with what was typed intact, because a mistyped
|
||||
/// address is the likeliest reason to want out — and drops the password, which is a secret nobody asked
|
||||
/// to keep.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EscapeFromThePasswordGoesBackToTheAddress()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
shell.AdHocPassword = "hunter2";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsSearching.ShouldBeTrue("Escape here steps back rather than closing");
|
||||
shell.IsAskingForAdHocPassword.ShouldBeFalse();
|
||||
shell.SearchText.ShouldBe("root@10.0.0.5");
|
||||
shell.AdHocPassword.ShouldBeEmpty();
|
||||
palette.QueryBox.IsFocused.ShouldBeTrue("the caret goes back to the address");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Enter with no password typed is refused in the palette rather than dialled: an SSH server asked for a
|
||||
/// password with an empty one answers "authentication failed", which is a sentence about the machine
|
||||
/// when the problem is the box.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnEmptyPasswordIsRefusedRatherThanDialled()
|
||||
{
|
||||
var started = new List<ConnectionAttemptEventArgs>();
|
||||
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
|
||||
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
|
||||
shell.IsSearching.ShouldBeTrue();
|
||||
shell.HasAdHocRefusal.ShouldBeTrue();
|
||||
started.ShouldBeEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole path: an address, a password, and a tab for that address — with nothing added to the
|
||||
/// keychain. The dial itself fails in this suite, as every connect here does, and inside the vault's own
|
||||
/// handling; what the palette promises is the attempt and the closing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePasswordConnectsToTheAddressAndSavesNothing()
|
||||
{
|
||||
var started = new List<ConnectionAttemptEventArgs>();
|
||||
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
|
||||
var hostsBefore = vault.Hosts.Count;
|
||||
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5:2222";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
shell.AdHocPassword = "hunter2";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
|
||||
shell.IsSearching.ShouldBeFalse("connecting closes the palette, as it does for a saved host");
|
||||
shell.AdHocPassword.ShouldBeEmpty("the password does not outlive the palette");
|
||||
|
||||
var attempt = started.ShouldHaveSingleItem();
|
||||
attempt.Address.ShouldBe("root@10.0.0.5:2222");
|
||||
attempt.HostId.ShouldBeNull("nothing in the keychain stands behind this tab");
|
||||
});
|
||||
|
||||
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The one time the typed address does not take the highlight: a saved host dials exactly that address,
|
||||
/// so it is the same machine with whatever the keychain knows about it, and Enter should reach it the way
|
||||
/// it was set up to be reached. The typed row is still offered, one arrow away.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressASavedHostDialsHighlightsThatHost()
|
||||
{
|
||||
await OnThePaletteAsync((_, _) =>
|
||||
{
|
||||
shell.SearchText = "deploy@host-3.internal";
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeTrue();
|
||||
shell.IsAdHocSelected.ShouldBeFalse();
|
||||
shell.SelectedSearchResult.ShouldNotBeNull().Label.ShouldBe("host-3");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The typed row sits above the list and the arrows treat it as the list's first row, clamped as the rest
|
||||
/// are — and the caret stays in the box throughout, for the reason the plain arrows test gives.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheArrowsMoveBetweenTheAddressAndTheSavedHosts()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
|
||||
// Every seeded host's address starts with this, and none of them is exactly it.
|
||||
shell.SearchText = "deploy@host";
|
||||
shell.SearchResults.Count.ShouldBeGreaterThan(1, "an empty list would prove nothing");
|
||||
shell.IsAdHocSelected.ShouldBeTrue();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
|
||||
shell.IsAdHocSelected.ShouldBeFalse();
|
||||
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
||||
shell.IsAdHocSelected.ShouldBeTrue();
|
||||
shell.SelectedSearchResult.ShouldBeNull();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
||||
shell.IsAdHocSelected.ShouldBeTrue("clamped rather than wrapped");
|
||||
|
||||
palette.QueryBox.IsFocused.ShouldBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>The pointer's version of Enter on the typed row: straight to its password.</remarks>
|
||||
[Fact]
|
||||
public async Task ClickingTheAddressAsksForItsPassword()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
Relayout(window);
|
||||
|
||||
var row = palette.AdHocTargetRow;
|
||||
window.MouseDown(Centre(row, window), MouseButton.Left);
|
||||
window.MouseUp(Centre(row, window), MouseButton.Left);
|
||||
|
||||
shell.IsAskingForAdHocPassword.ShouldBeTrue();
|
||||
shell.IsSearching.ShouldBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>
|
||||
|
||||
@@ -233,19 +233,26 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SPELUNK section with a key saved: the form, its note, and the row listing the key all fit, and the
|
||||
/// form's button is the real save.
|
||||
/// The SPELUNK section with this device signed in: the form, and the row listing the sign-in, all fit, and
|
||||
/// the form's button is the real sign-in.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TheSecurityPageFitsWithASavedSpelunkKey()
|
||||
public async Task TheSecurityPageFitsWhenSignedInToSpelunk()
|
||||
{
|
||||
vault.SpelunkKeyFormApi = "https://api.spelunk.example.com";
|
||||
vault.SpelunkKeyFormKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
await vault.SaveSpelunkKeyCommand.ExecuteAsync(null);
|
||||
await vault.Session.SpelunkSignIns.SaveAsync(
|
||||
new SpelunkSignInRecord(
|
||||
"https://api.spelunk.example.com",
|
||||
"spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
||||
Guid.CreateVersion7(),
|
||||
"laurens@example.com",
|
||||
"Laurens Example",
|
||||
DateTimeOffset.UtcNow),
|
||||
Token);
|
||||
await vault.ReloadSpelunkSignInsAsync(Token);
|
||||
|
||||
vault.SpelunkKeys.ShouldHaveSingleItem(vault.SpelunkKeyFormNote);
|
||||
vault.SpelunkSignIns.ShouldHaveSingleItem();
|
||||
|
||||
await MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("with a Spelunk key saved"));
|
||||
await MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("signed in to Spelunk"));
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
@@ -257,7 +264,7 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
|
||||
|
||||
try
|
||||
{
|
||||
ButtonNamed(window, "SAVE KEY").Command.ShouldBeSameAs(vault.SaveSpelunkKeyCommand);
|
||||
ButtonNamed(window, "SIGN IN").Command.ShouldBeSameAs(vault.SignInToSpelunkCommand);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -122,6 +122,20 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
|
||||
/// <inheritdoc />
|
||||
public IVaultEventStream Events => Notices;
|
||||
|
||||
/// <summary>What the identity provider answers when the vault checks the sign-in, or null for yes.</summary>
|
||||
/// <remarks>An <c>OidcException</c> with <c>invalid_grant</c> is an account the provider no longer signs in.</remarks>
|
||||
public Exception? SignInRefusal { get; set; }
|
||||
|
||||
/// <summary>How many times the vault checked the sign-in.</summary>
|
||||
public int SignInChecks { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task VerifySignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
SignInChecks++;
|
||||
return SignInRefusal is { } refusal ? Task.FromException(refusal) : Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The relay a relayed host is reached through. Unavailable unless a test sets one, which is what a
|
||||
/// server that does not advertise the feature supplies.
|
||||
|
||||
@@ -86,19 +86,86 @@ public sealed class LinuxUpdateChannelTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A machine that cannot say what it has treats everything as newer.
|
||||
/// A machine that cannot say what it has is told so, and is offered nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The wrong way to fail, chosen deliberately and for the reason the android channel gives: the cost is
|
||||
/// an install the package manager declines, and the cost of the other direction is a security fix that
|
||||
/// never arrives.
|
||||
/// This used to read the silence as <c>0</c> and offer everything, on the argument that the cost was
|
||||
/// an install the package manager declined. On a machine with no rpm at all — a pacman package of the
|
||||
/// same tree at the same path — that was every nightly, every six hours, as a 56 MB download nothing
|
||||
/// could install. Unknown is not older.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnUnknownInstalledVersionTreatsEverythingAsNewer()
|
||||
public async Task AnUnknownInstalledVersionThrowsRatherThanOfferingAnything()
|
||||
{
|
||||
var channel = Channel(RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.1-1", installed: null);
|
||||
var handed = new List<string>();
|
||||
var channel = Channel(
|
||||
RandomNumberGenerator.GetBytes(16),
|
||||
published: "0.0.1~alpha.0.200-1",
|
||||
installed: null,
|
||||
handToInstaller: handed.Add);
|
||||
|
||||
(await channel.CheckAsync(Token)).ShouldNotBeNull();
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await channel.DownloadAsync(
|
||||
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.200"),
|
||||
new Progress<int>(),
|
||||
Token));
|
||||
|
||||
handed.ShouldBeEmpty();
|
||||
Directory.Exists(Path.Combine(directory, "updates")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy rpm does not own gets no channel at all, and a sentence saying whose it is to update.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The case that matters: the right path and no rpm — a locally built pacman package that mirrors the
|
||||
/// spec. No binary and no such package both arrive here as null.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void ACopyRpmDoesNotOwn_IsUnavailableAndSaysWhy()
|
||||
{
|
||||
var asked = new List<string>();
|
||||
|
||||
var updates = LinuxUpdateChannel.ForInstall(
|
||||
"nightly",
|
||||
package =>
|
||||
{
|
||||
asked.Add(package);
|
||||
|
||||
return null;
|
||||
},
|
||||
() => throw new InvalidOperationException("Built a channel for a copy rpm does not own."));
|
||||
|
||||
asked.ShouldBe(["dodossh-nightly"]);
|
||||
updates.IsSupported.ShouldBeFalse();
|
||||
|
||||
var reason = updates.ShouldBeOfType<DodoSSH.Client.Session.UnavailableUpdateChannel>().Reason;
|
||||
|
||||
reason.ShouldNotBeNull();
|
||||
reason.ShouldContain("outside the dodossh-nightly RPM");
|
||||
reason.ShouldContain("package manager that installed it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACopyRpmOwns_GetsTheChannel()
|
||||
{
|
||||
var asked = new List<string>();
|
||||
var owned = new FakeUpdateChannel();
|
||||
|
||||
var updates = LinuxUpdateChannel.ForInstall(
|
||||
"release",
|
||||
package =>
|
||||
{
|
||||
asked.Add(package);
|
||||
|
||||
return "0.0.1-1";
|
||||
},
|
||||
() => owned);
|
||||
|
||||
asked.ShouldBe(["dodossh"]);
|
||||
updates.ShouldBeSameAs(owned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -114,6 +114,13 @@ public sealed partial class ShellFlowTests
|
||||
|
||||
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||
|
||||
// Waited for in its own right rather than taken as read from IsLive. Ended clears IsLive and then
|
||||
// ends the forwards in the same dispatcher turn, which the app's UI thread never splits — but this
|
||||
// harness's post runs the ending on the fake shell's thread, so a poll from here can land between.
|
||||
await EventuallyAsync(
|
||||
() => tab.Forwards.All(row => row.Detail == "Stopped with the session."),
|
||||
"the session's end should have reached its forwards");
|
||||
|
||||
tab.Forwards.ShouldAllBe(row => row.State == PortForwardState.Stopped);
|
||||
tab.Forwards.ShouldAllBe(row => row.Detail == "Stopped with the session.");
|
||||
tab.Forwards.ShouldAllBe(row => !row.CanToggle);
|
||||
@@ -145,13 +152,11 @@ public sealed partial class ShellFlowTests
|
||||
[Fact]
|
||||
public async Task ATypedInAddress_StartsNoForwards()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ManualTarget = "deploy@10.0.0.9";
|
||||
vault.ManualPassword = "hunter2";
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
await ConnectAtThePromptAsync("deploy@10.0.0.9", "hunter2");
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().Forwards.ShouldBeEmpty();
|
||||
ssh.Forwards.ShouldBeEmpty();
|
||||
|
||||
@@ -2,7 +2,9 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
@@ -19,22 +21,25 @@ namespace DodoSSH.Client.App.Tests;
|
||||
/// own HTTP and WebSocket half is <c>SpelunkTunnelTests</c>' job.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What these hold is what the tick box and the link promise: a Spelunk host is never dialled directly and
|
||||
/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, a sign-in
|
||||
/// connects with the tunnel's key and Spelunk's account and nothing of the host's, and a link connects
|
||||
/// straight away but never sends a key to a Spelunk no saved host already sends it to.
|
||||
/// What these hold is what the tick box, the sign-in and the link promise: a Spelunk host is never dialled
|
||||
/// directly and never probed, a refusal is said in Spelunk's terms, this device's sign-in to a Spelunk goes
|
||||
/// only to that Spelunk and only once DodoSSH's own account has been checked, a sign-in connects with the
|
||||
/// tunnel's key and Spelunk's account and nothing of the host's, and a link connects straight away but never
|
||||
/// starts a sign-in by itself.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class ShellFlowTests
|
||||
{
|
||||
private const string SpelunkApi = "https://api.spelunk.example.com";
|
||||
|
||||
private const string SpelunkApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
|
||||
private const string SpelunkToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
|
||||
|
||||
private static readonly Guid SpelunkServer = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
||||
|
||||
private readonly FakeSpelunkTunnel spelunkTunnels = new();
|
||||
|
||||
private readonly FakeSpelunkAccounts spelunkAccounts = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself()
|
||||
{
|
||||
@@ -43,9 +48,11 @@ public sealed partial class ShellFlowTests
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
// The key from the credential, to the Spelunk and server the host names, for the host's own port.
|
||||
// This device's sign-in, to the Spelunk and server the host names, for the host's own port — and only
|
||||
// once DodoSSH's own account has been checked.
|
||||
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||
opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
|
||||
opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22));
|
||||
server.SignInChecks.ShouldBeGreaterThan(0);
|
||||
|
||||
// Through the loopback proxy, and named as the host, because the name is what its key is pinned under.
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
@@ -127,23 +134,22 @@ public sealed partial class ShellFlowTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkHostWithNoKeyChosen_IsNotSaved()
|
||||
public async Task AHostOnASpelunkThisDeviceIsNotSignedInTo_IsRefusedAtConnect_AndOffersTheSignIn()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
await vault.Session.SpelunkSignIns.ForgetAllAsync(Token);
|
||||
await vault.ReloadSpelunkSignInsAsync(Token);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "web-01";
|
||||
vault.EditorHostname = "203.0.113.10";
|
||||
vault.EditorUsername = "deploy";
|
||||
vault.EditorThroughSpelunk = true;
|
||||
vault.EditorSpelunkApi = SpelunkApi;
|
||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("not signed in there");
|
||||
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("Spelunk API key");
|
||||
vault.Hosts.ShouldBeEmpty();
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
|
||||
vault.EditorSpelunkSignInNote.ShouldContain("not signed in to Spelunk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,22 +162,8 @@ public sealed partial class ShellFlowTests
|
||||
vault.EditorThroughSpelunk.ShouldBeTrue();
|
||||
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||
vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ACredentialHoldingASpelunkKey_IsInUse_WhereverThatIsDrawn()
|
||||
{
|
||||
// So deleting it is warned about like any other credential a host needs, rather than looking unused.
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||
var row = vault.VaultItems.Single(item => string.Equals(item.Name, "spelunk key", StringComparison.Ordinal));
|
||||
|
||||
row.UsedBySummary.ShouldBe("web-01");
|
||||
|
||||
vault.SelectedVaultItem = row;
|
||||
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("web-01");
|
||||
vault.EditorSpelunkNeedsSignIn.ShouldBeFalse();
|
||||
vault.EditorSpelunkSignInNote.ShouldContain("ops@example.com");
|
||||
}
|
||||
|
||||
// ---- Signing in through Spelunk ----
|
||||
@@ -188,7 +180,7 @@ public sealed partial class ShellFlowTests
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
|
||||
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22));
|
||||
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 ");
|
||||
|
||||
@@ -201,7 +193,7 @@ public sealed partial class ShellFlowTests
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
shell.Tabs.ShouldHaveSingleItem();
|
||||
vault.Credentials.Count.ShouldBe(1, "nothing is remembered for a sign-in");
|
||||
vault.Credentials.ShouldBeEmpty("nothing is remembered for a sign-in");
|
||||
|
||||
await request.Proxy.Lifetime.DisposeAsync();
|
||||
}
|
||||
@@ -319,11 +311,11 @@ public sealed partial class ShellFlowTests
|
||||
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||
|
||||
// No key chosen for a Spelunk no host already uses: the link named the address, and guessing a key
|
||||
// for it would be offering to send the key wherever a link says.
|
||||
vault.EditorSelectedSpelunkKey.ShouldBeNull();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("first server");
|
||||
// Not signed in to that Spelunk, and a link never starts a sign-in by itself: the editor offers it.
|
||||
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("Sign in to that Spelunk");
|
||||
|
||||
spelunkAccounts.SignedIn.ShouldBeEmpty();
|
||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
vault.Hosts.ShouldBeEmpty("nothing is saved until the user saves it");
|
||||
@@ -353,9 +345,11 @@ public sealed partial class ShellFlowTests
|
||||
chip => chip.Label.ShouldBe(VaultViewModel.SpelunkTag),
|
||||
chip => chip.IsWorn.ShouldBeTrue());
|
||||
|
||||
// So once it is saved, the next link for that server finds it rather than adding a second one.
|
||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
||||
// Signed in from the editor and saved, the next link for that server finds it rather than adding a
|
||||
// second one.
|
||||
await vault.SignInToEditorSpelunkCommand.ExecuteAsync(null);
|
||||
spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]);
|
||||
vault.EditorSpelunkNeedsSignIn.ShouldBeFalse();
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
@@ -376,10 +370,10 @@ public sealed partial class ShellFlowTests
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: null, server: other));
|
||||
|
||||
vault.IsEditing.ShouldBeFalse("nothing to choose: the key already goes to this Spelunk");
|
||||
vault.IsEditing.ShouldBeFalse("nothing to choose: this device is signed in to this Spelunk");
|
||||
|
||||
// Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk with the
|
||||
// key that Spelunk already gets, and no login of its own.
|
||||
// Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk, and no
|
||||
// login of its own.
|
||||
var added = vault.Hosts.Single(row => row.Host.Spelunk?.ServerId == other);
|
||||
added.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId);
|
||||
added.Label.ShouldBe("web 01");
|
||||
@@ -387,7 +381,6 @@ public sealed partial class ShellFlowTests
|
||||
added.Host.Port.ShouldBe(22);
|
||||
added.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi);
|
||||
added.Host.Spelunk.SignInThroughSpelunk.ShouldBeTrue();
|
||||
added.Host.Spelunk.ApiKeyCredentialId.ShouldBe(vault.Credentials.Single(row => row.Label == "spelunk key").EntityId);
|
||||
added.Host.SshKeyId.ShouldBeNull();
|
||||
added.Host.CredentialId.ShouldBeNull();
|
||||
added.Host.Username.ShouldBeNull();
|
||||
@@ -395,7 +388,7 @@ public sealed partial class ShellFlowTests
|
||||
|
||||
// And connected, through Spelunk, signing in.
|
||||
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||
opened.ShouldBe((SpelunkApi, other, SpelunkApiKey, 22));
|
||||
opened.ShouldBe((SpelunkApi, other, SpelunkToken, 22));
|
||||
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
@@ -507,70 +500,45 @@ public sealed partial class ShellFlowTests
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("Finish or cancel");
|
||||
}
|
||||
|
||||
// ---- A Spelunk key saved on the Security page ----
|
||||
// ---- Signing this device in to Spelunk ----
|
||||
|
||||
[Fact]
|
||||
public async Task AKeySavedOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway()
|
||||
public async Task ASignInOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await SaveSpelunkKeyAsync(vault, SpelunkApi + "/", SpelunkApiKey);
|
||||
await SignInToSpelunkAsync(vault, SpelunkApi + "/");
|
||||
|
||||
// Saved in the one form, in the personal vault, and gone from the form.
|
||||
var saved = vault.SpelunkKeys.ShouldHaveSingleItem();
|
||||
saved.ApiBase.ShouldBe(SpelunkApi);
|
||||
saved.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId);
|
||||
vault.SpelunkKeyFormKey.ShouldBeEmpty();
|
||||
// Signed in through the browser, for the address in its one form, and kept on this device.
|
||||
spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]);
|
||||
var signedIn = vault.SpelunkSignIns.ShouldHaveSingleItem();
|
||||
signedIn.ApiBase.ShouldBe(SpelunkApi);
|
||||
signedIn.Who.ShouldContain("ops@example.com");
|
||||
vault.SpelunkSignInFormNote.ShouldContain("Signed in");
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.IsEditing.ShouldBeFalse("saving the key for this Spelunk is the say-so the first link used to ask for");
|
||||
vault.IsEditing.ShouldBeFalse("signing in to this Spelunk is the say-so a link needs");
|
||||
|
||||
var added = vault.Hosts.ShouldHaveSingleItem();
|
||||
added.Host.Spelunk.ShouldNotBeNull().ApiKeyCredentialId.ShouldBe(saved.EntityId);
|
||||
added.Host.SignsInThroughSpelunk.ShouldBeTrue();
|
||||
|
||||
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
|
||||
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22));
|
||||
|
||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASecondKeyForTheSameSpelunk_ReplacesTheFirst_AndHostsUseTheNewOne()
|
||||
public async Task ASignIn_IsNeverSentToAnotherSpelunk()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await SaveSpelunkKeyAsync(vault, SpelunkApi, "spelunk_the_old_key");
|
||||
await SignInToSpelunkAsync(vault, SpelunkApi);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link());
|
||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
|
||||
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
|
||||
|
||||
vault.SpelunkKeys.ShouldHaveSingleItem().UsedBy.ShouldBe("1 host uses it");
|
||||
vault.SpelunkKeyFormNote.ShouldContain("Replaced");
|
||||
|
||||
await vault.OpenSpelunkLinkAsync(Link());
|
||||
|
||||
spelunkTunnels.Opened.Select(opened => opened.ApiKey).ShouldBe(["spelunk_the_old_key", SpelunkApiKey]);
|
||||
|
||||
await ssh.Requests[1].Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASavedKey_IsNeverSentToAnotherSpelunk()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
|
||||
var saved = vault.SpelunkKeys.ShouldHaveSingleItem();
|
||||
|
||||
// A host that names the key but another address — the one thing a key kept with its address refuses.
|
||||
// A host on a Spelunk this device did not sign in to: the sign-in it has is for somewhere else.
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "web-01";
|
||||
vault.EditorHostname = "203.0.113.10";
|
||||
@@ -578,7 +546,7 @@ public sealed partial class ShellFlowTests
|
||||
vault.EditorSpelunkSignIn = true;
|
||||
vault.EditorSpelunkApi = "https://spelunk.somewhere-else.example";
|
||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.EntityId == saved.EntityId);
|
||||
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status);
|
||||
@@ -588,85 +556,198 @@ public sealed partial class ShellFlowTests
|
||||
|
||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("never sent anywhere else");
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("not signed in there");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TypingTheAddressOfASavedKey_PicksThatKey_InTheEditor()
|
||||
public async Task AnAddressThatWouldSendTheSignInInClear_IsRefusedOnTheForm()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddCredentialAsync(vault, "an older key", password: "spelunk_older");
|
||||
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
|
||||
await SignInToSpelunkAsync(vault, "http://api.spelunk.example.com");
|
||||
|
||||
spelunkAccounts.SignedIn.ShouldBeEmpty("no browser is opened for an address that would be refused");
|
||||
vault.SpelunkSignIns.ShouldBeEmpty();
|
||||
vault.SpelunkSignInFormNote.ShouldContain("https");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASignInCancelledInTheBrowser_IsSaid_AndNothingIsKept()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
spelunkAccounts.Refusal = new SpelunkSignInException("Signing in to Spelunk was cancelled in the browser.");
|
||||
await SignInToSpelunkAsync(vault, SpelunkApi);
|
||||
|
||||
vault.SpelunkSignIns.ShouldBeEmpty();
|
||||
vault.SpelunkSignInFormNote.ShouldContain("cancelled in the browser");
|
||||
vault.IsSigningInToSpelunk.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheEditor_SaysWhoItReachesSpelunkAs_AndSignsInFromThere()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorThroughSpelunk = true;
|
||||
vault.EditorSpelunkApi = SpelunkApi + "/";
|
||||
|
||||
var chosen = vault.EditorSelectedSpelunkKey.ShouldNotBeNull();
|
||||
chosen.EntityId.ShouldBe(vault.SpelunkKeys.Single().EntityId);
|
||||
chosen.Qualifier.ShouldBe($"Spelunk key · {SpelunkApi}");
|
||||
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
|
||||
|
||||
// Saved keys come before credentials, which stay on offer for keys kept the older way.
|
||||
vault.EditorSpelunkKeyChoices.Select(choice => choice.Label)
|
||||
.ShouldBe(["Spelunk at api.spelunk.example.com", "an older key"]);
|
||||
await vault.SignInToEditorSpelunkCommand.ExecuteAsync(null);
|
||||
|
||||
spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]);
|
||||
vault.EditorSpelunkNeedsSignIn.ShouldBeFalse();
|
||||
vault.SpelunkSignIns.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnAddressThatWouldSendTheKeyInClear_IsRefusedOnTheForm()
|
||||
public async Task SpelunkSigningThisDeviceOut_ForgetsTheSignIn_SoTheNextConnectAsksForANewOne()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
spelunkTunnels.Refusal = new SpelunkTunnelException(
|
||||
"Spelunk signed this device out: your password changed.", HttpStatusCode.Unauthorized);
|
||||
|
||||
await SaveSpelunkKeyAsync(vault, "http://api.spelunk.example.com", SpelunkApiKey);
|
||||
|
||||
vault.SpelunkKeys.ShouldBeEmpty();
|
||||
vault.SpelunkKeyFormNote.ShouldContain("https");
|
||||
vault.SpelunkKeyFormKey.ShouldBe(SpelunkApiKey, "a refused key stays in the box to be saved once the address is fixed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletingASavedKey_SaysWhichHostsWillStopConnecting()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link());
|
||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.DeleteSpelunkKeyRowCommand.Execute(vault.SpelunkKeys.Single());
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("signed this device out");
|
||||
vault.SpelunkSignIns.ShouldBeEmpty();
|
||||
(await vault.Session.SpelunkSignIns.ListAsync(Token)).ShouldBeEmpty();
|
||||
|
||||
var request = vault.PendingDeletion.ShouldNotBeNull();
|
||||
request.Usage.ShouldContain("web 01 reaches Spelunk with it");
|
||||
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SpelunkKeys.ShouldBeEmpty();
|
||||
|
||||
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts.Single());
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("not in this keychain any more");
|
||||
// The next attempt does not repeat the refusal: it asks to sign in.
|
||||
spelunkTunnels.Refusal = null;
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
spelunkTunnels.Opened.Count.ShouldBe(1);
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("not signed in there");
|
||||
}
|
||||
|
||||
private static async Task SaveSpelunkKeyAsync(VaultViewModel vault, string apiBase, string apiKey)
|
||||
[Fact]
|
||||
public async Task AServerOrOrganisationSpelunkRefuses_KeepsTheSignIn()
|
||||
{
|
||||
vault.SpelunkKeyFormApi = apiBase;
|
||||
vault.SpelunkKeyFormKey = apiKey;
|
||||
// A 403 is about this server, not about who is signed in: every other server stays reachable.
|
||||
spelunkTunnels.Refusal = new SpelunkTunnelException(
|
||||
"You are not allowed to open a terminal on this server.", HttpStatusCode.Forbidden);
|
||||
|
||||
await vault.SaveSpelunkKeyCommand.ExecuteAsync(null);
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("not allowed");
|
||||
vault.SpelunkSignIns.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
/// <summary>A saved host through Spelunk, bound to a stored SSH password, with its key in the keychain.</summary>
|
||||
[Fact]
|
||||
public async Task ADodoSshAccountTheProviderRefuses_ReachesNoSpelunk_AndSignsThisDeviceOut()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
server.SignInRefusal = new OidcException("The account is disabled.", "invalid_grant");
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
spelunkTunnels.Opened.ShouldBeEmpty("Spelunk is not reached for an account DodoSSH no longer has");
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("no longer signs in");
|
||||
|
||||
// Signed out of every Spelunk, there and here, and of DodoSSH: offline, and saying why.
|
||||
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
|
||||
vault.SpelunkSignIns.ShouldBeEmpty();
|
||||
shell.IsOnline.ShouldBeFalse();
|
||||
vault.OfflineReason.ShouldNotBeNull().ShouldContain("refused");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADodoSshSignInThatCannotBeChecked_ReachesNoSpelunk_ButIsKept()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
server.SignInRefusal = new HttpRequestException("The identity provider could not be reached.");
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("could not check");
|
||||
vault.SpelunkSignIns.ShouldHaveSingleItem("a provider that is down is not an account that is gone");
|
||||
shell.IsOnline.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADodoSshSignInRefusedOnResume_SignsThisDeviceOutOfEverySpelunkToo()
|
||||
{
|
||||
await ReadyToConnectThroughSpelunkAsync();
|
||||
await shell.Vault!.SyncOnOpenAsync(Token);
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
spelunkAccounts.SignedOut.ShouldBeEmpty();
|
||||
|
||||
resumeFailure = new OidcException("The token endpoint returned 400: Account disabled.", "invalid_grant");
|
||||
|
||||
var relaunch = Relaunch(resume: ResumeAsync);
|
||||
await using var _ = relaunch.ConfigureAwait(false);
|
||||
|
||||
await relaunch.StartAsync(Token);
|
||||
relaunch.Passphrase = Passphrase;
|
||||
await relaunch.UnlockCommand.ExecuteAsync(null);
|
||||
await relaunch.Vault.ShouldNotBeNull().SyncOnOpenAsync(Token);
|
||||
|
||||
// The token Spelunk is told to end is the one the first launch kept: the sign-in outlived the lock and
|
||||
// the relaunch, and went only when DodoSSH's own did.
|
||||
relaunch.IsOnline.ShouldBeFalse();
|
||||
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
|
||||
relaunch.Vault.SpelunkSignIns.ShouldBeEmpty();
|
||||
(await relaunch.Vault.Session.SpelunkSignIns.ListAsync(Token)).ShouldBeEmpty();
|
||||
relaunch.Vault.Status.ShouldNotBeNull().ShouldContain("signed out of Spelunk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SigningOutOfSpelunk_OnTheSecurityPage_EndsItThereAndHere()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await vault.SignOutOfSpelunkCommand.ExecuteAsync(vault.SpelunkSignIns.Single());
|
||||
|
||||
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
|
||||
vault.SpelunkSignIns.ShouldBeEmpty();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SigningOutOfDodoSsh_SignsOutOfEverySpelunk()
|
||||
{
|
||||
await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
shell.SignOutCommand.Execute(null);
|
||||
await shell.ConfirmSignOutCommand.ExecuteAsync(null);
|
||||
|
||||
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
|
||||
}
|
||||
|
||||
private static async Task SignInToSpelunkAsync(VaultViewModel vault, string apiBase)
|
||||
{
|
||||
vault.SpelunkSignInFormApi = apiBase;
|
||||
|
||||
await vault.SignInToSpelunkCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>A saved host through Spelunk, bound to a stored SSH password, on a Spelunk this device is signed in to.</summary>
|
||||
private async Task<VaultViewModel> ReadyToConnectThroughSpelunkAsync()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
||||
await SignInToSpelunkAsync(vault, SpelunkApi);
|
||||
await AddCredentialAsync(vault, "deploy password", password: "s3cret", username: "deploy");
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
@@ -676,7 +757,6 @@ public sealed partial class ShellFlowTests
|
||||
vault.EditorThroughSpelunk = true;
|
||||
vault.EditorSpelunkApi = SpelunkApi + "/";
|
||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||
.Single(choice => choice.Label == "deploy password");
|
||||
|
||||
@@ -697,7 +777,7 @@ public sealed partial class ShellFlowTests
|
||||
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
||||
await SignInToSpelunkAsync(vault, SpelunkApi);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "web-01";
|
||||
@@ -706,7 +786,6 @@ public sealed partial class ShellFlowTests
|
||||
vault.EditorSpelunkSignIn = true;
|
||||
vault.EditorSpelunkApi = SpelunkApi;
|
||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
@@ -748,7 +827,7 @@ public sealed partial class ShellFlowTests
|
||||
/// </summary>
|
||||
private sealed class FakeSpelunkTunnel : ISpelunkTunnel
|
||||
{
|
||||
internal List<(string ApiBase, Guid ServerId, string ApiKey, int Port)> Opened { get; } = [];
|
||||
internal List<(string ApiBase, Guid ServerId, string Token, int Port)> Opened { get; } = [];
|
||||
|
||||
/// <summary>The sign-in key each tunnel was asked for, in order; null for a plain tunnel.</summary>
|
||||
internal List<SpelunkSignIn?> SignIns { get; } = [];
|
||||
@@ -764,12 +843,12 @@ public sealed partial class ShellFlowTests
|
||||
public async Task<SpelunkTunnelRoute> OpenAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
string token,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Opened.Add((apiBase, serverId, apiKey, port));
|
||||
Opened.Add((apiBase, serverId, token, port));
|
||||
SignIns.Add(signIn);
|
||||
|
||||
if (Refusal is { } refusal)
|
||||
@@ -806,4 +885,32 @@ public sealed partial class ShellFlowTests
|
||||
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero }));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Signing in to Spelunk as far as the shell can see it: no browser, a sign-in or a refusal.</summary>
|
||||
private sealed class FakeSpelunkAccounts : ISpelunkAccounts
|
||||
{
|
||||
/// <summary>The Spelunks a sign-in was asked for, in order.</summary>
|
||||
internal List<string> SignedIn { get; } = [];
|
||||
|
||||
/// <summary>The tokens that were signed out, in order.</summary>
|
||||
internal List<string> SignedOut { get; } = [];
|
||||
|
||||
internal SpelunkSignInException? Refusal { get; set; }
|
||||
|
||||
public Task<SpelunkSignInRecord> SignInAsync(string apiBase, CancellationToken cancellationToken)
|
||||
{
|
||||
SignedIn.Add(apiBase);
|
||||
|
||||
return Refusal is { } refusal
|
||||
? Task.FromException<SpelunkSignInRecord>(refusal)
|
||||
: Task.FromResult(new SpelunkSignInRecord(
|
||||
apiBase, SpelunkToken, Guid.CreateVersion7(), "ops@example.com", "Ops", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken)
|
||||
{
|
||||
SignedOut.Add(signIn.Token);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user