Compare commits

..
Author SHA1 Message Date
jaap-jan ad1a129159 CI: publish the desktop nightly with curl, since vpk's Gitea client cannot read the upgraded forge's replies
ci / build and test (push) Successful in 3m35s
ci / android head (push) Successful in 3m43s
ci / api image (push) Successful in 7s
ci / desktop nightly (push) Successful in 1m25s
Moving vpk to 1.2.161 was not enough: it got further and failed the same way, one frame later.
Both versions use a generated Gitea SDK that gets null back from 'create release' on the forge's
28.0.0, although the release is created, so the step threw before uploading anything and left an
empty draft behind on every push since 2 October.

The upload is now the android job's curl pattern. The release is created as a draft prerelease,
the three packages go up, then releases.win-nightly.json, which vpk pack already wrote and which is
exactly the index vpk upload would have generated, and only then is it published. The read-back
check, the RPM and the Linux manifest after it are unchanged.
2026-10-03 19:56:53 +02:00
jaap-jan e240c0cf26 Tests: prove the device-key probe runs off the caller's thread by blocking it, not by comparing thread ids
ci / build and test (push) Successful in 3m25s
ci / android head (push) Successful in 3m55s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 23s
TheChoiceIsMadeOnce_OffTheCallersThread failed on CI with the code doing the right thing. It
compared the probe's managed thread id with the test's, but the test runs on a pool thread, and that
same thread is free to pick up the queued probe as soon as the test awaits.

The probe now waits on a release the test only gives once construction has returned. A probe run on
the constructing thread could never be released, and the test fails after ten seconds instead of
passing by luck. Twenty runs pass; an inline probe fails it.
2026-10-03 19:50:25 +02:00
jaap-jan 96dd176121 CI: move vpk and Velopack to 1.2.161, whose Gitea upload survives the forge's new release responses
ci / build and test (push) Failing after 2m54s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m41s
The desktop nightly has failed on every push to main since 2 October, at 'publish the nightly
release'. vpk 1.2.0 creates the draft release and then reads release.Assets from the forge's
answer, which the upgraded server (28.0.0) no longer fills in for a new release, so it threw a
NullReferenceException at GiteaRepository.cs:136. The step deletes the previous nightly-desktop
release before it uploads, so desktop clients have had no nightly to update from since.

1.2.161 reads the assets null-safely. The library moves with the tool, since Velopack expects the
two to match. Every vpk option the workflow and the release scripts pass is present in 1.2.161;
the only renamed ones are two MSI options nothing here uses.
2026-10-03 19:44:25 +02:00
jaap-jan 367cebae9b Android: a customisable key bar, a quick-access keyboard, a long-press cursor gesture, and a side panel on tablets
ci / android head (push) Successful in 5m59s
ci / api image (push) Successful in 8s
ci / build and test (push) Successful in 4m52s
ci / desktop nightly (push) Failing after 58s
The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen
in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt
with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control,
symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a
customise sheet that saves as it goes.

A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS,
SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's
existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing
typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now
keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail).

Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance;
scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press,
so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns
the gesture off. The text-size buttons moved from the bar to APPEARANCE.

On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with
the bar's own keys repeated at the top of KEYS while the bar stands down.

The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by
TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the
emulator's install is locked behind a passphrase that was not recorded.
2026-10-03 14:29:22 +02:00
jaap-jan c21de4ade4 Docs: the 2026-10-02 audit reports, the full sweep and the Android design audit, with their screenshots
ci / build and test (push) Successful in 4m49s
ci / android head (push) Successful in 5m26s
ci / api image (push) Successful in 9s
ci / desktop nightly (push) Failing after 56s
REPORT.md is the full-app sweep and what became of each finding. ANDROID-DESIGN.md is a walk through
every phone screen on the emulator, from a cleared install to SFTP, with findings ranked by severity
and a screenshot for each. The home-folder listing in the sweep's transfers screenshot is blurred.
2026-10-03 11:43:03 +02:00
jaap-jan 858e36279d Sync: after a read from the beginning, remove what the server no longer has, and flag an edit that brings a forgotten item back
ci / build and test (push) Successful in 3m38s
ci / android head (push) Successful in 3m46s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 48s
The server now forgets a deletion past its retention and refuses cursors
from before it. The client then reads everything again, but everything
again says nothing about what went. So a machine away for longer than the
retention kept those items for good, and its next edit of one put it back
on the server for everyone.

A read from the beginning now marks each item it reads, keeping the mark in
the sync state because the read can span passes. When it finishes, it
removes the items of the synced types it never marked. Two kinds are kept:
items newer than the read's bound, which this machine pushed during it, and
items with a change still waiting, which are the user's work. For those,
the push finds the server without the item and sends it as a create, as
before, but now records a RecreatedAfterCollection conflict and counts it,
so the sync message says so instead of undoing someone's deletion silently.

The bound is the highest sequence the cache held when the read began, or
anything higher it has read since. Taking only what the read returned would
have left the last item of a vault in place for ever.
2026-10-02 17:36:11 +02:00
jaap-jan 55e5de0ce4 Server: a cursor carries the sync horizon it was issued under, so a read from the beginning after a collection is not refused on its own pages
A cursor behind the horizon was refused on position alone. But the read a
refused client starts from the beginning is itself behind the horizon on
every page until it passes the collected deletion, since older untouched
items come first. So its second page was refused, it started over, and it
never finished, on any vault larger than one page.

The cursor now records the horizon read before the page that produced it,
and only a cursor issued under an older horizon is refused. A cursor issued
under no horizon keeps the v1 format, so a server from before this still
reads it.
2026-10-02 17:36:03 +02:00
jaap-jan 8cb1457ca9 Server: collect tombstones past their retention, compact superseded change entries, and refuse cursors from before a collection 2026-10-02 17:18:22 +02:00
jaap-jan 656a33b4b1 Desktop: build only the host cards near the view, with a virtualising wrap panel for the board
ci / build and test (push) Successful in 3m39s
ci / android head (push) Successful in 3m59s
ci / api image (push) Successful in 5s
ci / desktop nightly (push) Failing after 37s
2026-10-02 16:20:51 +02:00
jaap-jan 41f368ae75 Merge remote-tracking branch 'origin/main' into claude/audit-followups
ci / build and test (push) Successful in 3m45s
ci / android head (push) Successful in 4m15s
ci / desktop nightly (push) Failing after 43s
ci / api image (push) Successful in 46s
2026-10-02 15:27:39 +02:00
jaap-jan 24c18205c3 Desktop: rebuild the hosts board once the typing in the find box pauses, not once a letter 2026-10-02 15:23:58 +02:00
jaap-jan 8759b12d47 Desktop: virtualise the transfer queue and the import lists 2026-10-02 15:14:34 +02:00
jaap-jan 7b072bcded Desktop: swap the host lists in whole instead of announcing every row to listeners that redo all their work per row 2026-10-02 15:07:08 +02:00
jaap-jan b1d4a270d4 Merge pull request 'Audit follow-ups: keyboard, wording, performance, transfer, sync, retry and relay fixes' (#22) from claude/audit-followups into main
ci / build and test (push) Failing after 3m10s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 4m7s
Reviewed-on: #22
2026-10-02 12:59:55 +00:00
jaap-jan a2362d1e3e Desktop: decide a titlebar double-click on the press, before the drag can swallow it
ci / build and test (pull_request) Successful in 2m48s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 13s
ci / android head (pull_request) Successful in 3m41s
2026-10-02 14:40:50 +02:00
jaap-jan 079a273d46 Desktop: let a lock pressed during an unlock win, instead of the unlock finishing behind it 2026-10-02 14:37:52 +02:00
jaap-jan 37e9d74938 Desktop: keep a vault-visibility failure from closing the app, and leave a crash log when anything else does 2026-10-02 14:28:27 +02:00
jaap-jan 170caa1391 Relay: end a user's sessions into a vault the moment they lose access to it 2026-10-02 14:26:44 +02:00
jaap-jan 965c9a7043 Server: stop a team being archived while a vault is created in it 2026-10-02 14:20:30 +02:00
jaap-jan ff8a790f6c Sync: take the server having no such item as a delete done, not a rejection nobody can clear 2026-10-02 14:18:28 +02:00
jaap-jan b2516ca7fb Server: start a retried write transaction from the database, not from the attempt that failed 2026-10-02 14:15:52 +02:00
jaap-jan 36fb0817a4 Transfers: say so when a drop or a download is refused because the screen is busy 2026-10-02 14:11:41 +02:00
jaap-jan cc3d46f78c Transfers: let cancel pressed as a transfer finishes do nothing rather than throw 2026-10-02 14:10:40 +02:00
jaap-jan 8bee476039 Transfers: stop a transfer queued as the last one finishes from waiting for ever 2026-10-02 14:09:47 +02:00
jaap-jan 441709f1e9 Desktop: stop decrypting the whole keychain on a sync pass that changed nothing 2026-10-02 14:08:08 +02:00
jaap-jan 873405e591 Sync: mirror a pulled page in one write and read the outbox once per page, not twice per change 2026-10-02 14:04:36 +02:00
jaap-jan f997e87c5f SSH: open a shell off the caller's thread, and stop waiting when the caller gives up 2026-10-02 14:02:18 +02:00
jaap-jan 365954c541 Desktop: probe for the TPM once, off the UI thread, instead of on every unlock 2026-10-02 14:00:12 +02:00
142 changed files with 10544 additions and 676 deletions

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@
"rollForward": false
},
"vpk": {
"version": "1.2.0",
"version": "1.2.161",
"commands": [
"vpk"
],
+48 -24
View File
@@ -1221,27 +1221,55 @@ jobs:
fi
curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true
# vpk rather than curl, unlike the android job above, and the difference is what is being
# uploaded. An APK is one file the client is told about by a manifest this repository writes; a
# Velopack release is a set of files plus an index whose format is Velopack's own. Writing that
# index by hand would be reimplementing the tool that is already here.
# curl, like the android job above, and no longer `vpk upload gitea`. vpk's Gitea client is a
# generated SDK that cannot read the forge's answer to "create a release" since its upgrade to
# 28.0.0: the release is created and the client gets null back, so both 1.2.0 and 1.2.161 threw a
# NullReferenceException before uploading anything, leaving an empty draft behind on every push.
# Nothing here writes Velopack's index by hand: `vpk pack` already wrote releases.win-nightly.json
# beside the packages, and with no earlier release downloaded into that directory it is exactly
# the index `vpk upload` would have generated from it.
#
# --pre is load-bearing twice over. It keeps this out of the release channel, which refuses
# A draft first, published only once everything is on it, which is the order vpk used too: the
# client reads the index and then fetches what it names, and a published release missing a
# package is a few seconds in which every desktop is told to download something that is not
# there. The index goes up last for the same reason.
#
# prerelease is load-bearing twice over. It keeps this out of the release channel, which refuses
# prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
# channel reads: a desktop nightly published as a stable release would become the newest release
# in this repository and every phone on the release channel would start failing its update check
# against a release carrying no android manifest.
dotnet vpk upload gitea \
--skip-updates \
--repoUrl "$FORGE/$REPO" \
--token "$TOKEN" \
--outputDir "$RUNNER_TEMP/nightly-releases" \
--channel win-nightly \
--tag "$TAG" \
--releaseName "Nightly desktop $VERSION" \
--targetCommitish "$GITHUB_SHA" \
--pre \
--publish
releases="$RUNNER_TEMP/nightly-releases"
created="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$(printf '{"tag_name":"%s","target_commitish":"%s","name":"Nightly desktop %s","draft":true,"prerelease":true,"body":"Built from %s by CI. Installs beside the release build, never over it."}' \
"$TAG" "$GITHUB_SHA" "$VERSION" "$GITHUB_SHA")" \
"$api/releases")"
draft="$(printf '%s' "$created" | release_id)"
if [ -z "$draft" ]; then
echo "Gitea accepted the release call and returned no id:" >&2
printf '%s\n' "$created" >&2
exit 1
fi
for file in "$releases"/*-full.nupkg "$releases"/*-Portable.zip "$releases"/*-Setup.exe \
"$releases"/releases.win-nightly.json; do
if [ ! -f "$file" ]; then
echo "vpk pack did not leave $file behind:" >&2
ls -la "$releases" >&2
exit 1
fi
name="$(basename "$file")"
echo "Uploading $name ($(wc -c < "$file") bytes)"
curl -fsS -X POST -H "$auth" \
-F "attachment=@$file" \
"$api/releases/$draft/assets?name=$name" >/dev/null
done
curl -fsS -X PATCH -H "$auth" -H 'Content-Type: application/json' \
-d '{"draft":false}' \
"$api/releases/$draft" >/dev/null
# Asked for back rather than assumed, and the android job's history is why: it once created a
# release, uploaded nothing, and reported success for every upload it never made. A nightly
@@ -1260,20 +1288,16 @@ jobs:
fi
done
# ◆ THE RPM GOES ONTO THE RELEASE vpk JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
# ◆ THE RPM GOES ONTO THE RELEASE JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
#
# One rolling release carrying both heads, because they are one nightly: the same commit,
# the same version, and one page a person looks at to find out what is on main. A second tag
# would double the delete-and-recreate dance this step already does, and would let the two
# drift apart on a run where one of them failed.
#
# It has to happen here, after `vpk upload`, and not in a step before it. vpk deletes and
# recreates this release, so an RPM uploaded first would be attached to something that no
# longer exists by the time the job ends — silently, because the delete succeeds.
#
# curl rather than vpk, unlike the assets above, for the reason the android job gives about
# its APK: this is one file the release simply carries. There is no index to write and no
# format that belongs to a tool.
# It has to happen here, after the Windows upload, and not in a step before it. This step
# deletes and recreates the release, so an RPM uploaded first would be attached to something
# that no longer exists by the time the job ends — silently, because the delete succeeds.
release="$(printf '%s' "$published" | release_id)"
if [ -z "$release" ]; then
echo "The release was published and its id could not be read back:" >&2
+1 -1
View File
@@ -172,7 +172,7 @@
constant rather than a setting: an operator who could answer the update check could pin a
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
-->
<PackageVersion Include="Velopack" Version="1.2.0" />
<PackageVersion Include="Velopack" Version="1.2.161" />
<!--
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
+328
View File
@@ -0,0 +1,328 @@
# Android design audit — 2026-10-02
Run on the emulator (`dodossh_api36`, Pixel 5 profile, 1080×2340 @ 440 dpi, Android 16). It used a
standalone **Debug** APK from `main` at `858e362`, against a local API, Keycloak and a throwaway `sshd`.
The walk went from a cleared install through sign-in, enrolment, recovery code, unlock, adding a host,
connecting, host-key trust, terminal, and every Settings sub-page.
Screenshots are in [`android/`](android/), downscaled. The recovery-code screen can't be captured,
because `FLAG_SECURE` blocks it, so it was reviewed from its markup and the accessibility tree.
**How to read the evidence.** "a11y tree" means `uiautomator dump`, which reads what TalkBack and any
other accessibility service see. Times and hangs come from a Debug build on an emulator (JIT, no AOT),
so treat any performance note as "confirm on Release".
---
## What works well
- **Connection progress card** ([11](android/11-connecting.png)): five named steps, the current one lit,
with *close* and *logs* available while it's in progress.
- **Host-key sheet** ([13](android/13-host-key.png)): "UNKNOWN HOST KEY", first contact, the algorithm,
the full SHA256 fingerprint, and an instruction to compare. **TRUST AND CONNECT** and **CANCEL** are
both full width.
- **Recovery-code screen**:
- no back and no skip;
- **CONTINUE** stays disabled until the tick;
- a full-width **COPY CODE**;
- capture is blocked.
- **Terminal** ([14](android/14-terminal.png)):
- resizes above the keyboard;
- the two-row accessory bar (esc, tab, ctrl, `| - ~ /`, arrows, A−/A+) stays reachable;
- the open-sessions strip and the tab strip are clear.
- **Busy states**: enrolment disables its fields and button and explains the wait ("deliberately takes a
moment").
- **Touch targets** measure 44 dp or more almost everywhere: checkboxes, buttons, rows.
- **Back** closes sheets. The soft keyboard's ✓ submits single-field forms (unlock, connect).
- **Copy is honest and specific**, e.g. "works with no network — the keychain decrypts on this phone",
and the Vaults explanation.
---
## Critical / high
### H1 · Password fields expose their plaintext to accessibility services
- **What:** the vault passphrase field ([05](android/05-unlock-prefilled.png)) and the SSH password field
([10](android/10-connect-sheet.png)) both report `password="false"` in the a11y tree. Their `text`
attribute is the secret itself, in clear.
- **Evidence:** the a11y tree on both screens; the values matched exactly what was typed (redacted here).
- **Why it matters:** any installed app holding an accessibility service can read every passphrase and
host password typed into DodoSSH. That's a common Android malware technique. TalkBack would also read
the passphrase aloud character by character.
- **Where:** `TextBox.secret` sets `PasswordChar` and `ContentType=Password`
(`Theme/Phone.axaml:583`). That's correct for the input method, but Avalonia's Android automation peer
ignores it.
- **Fix:** give secret boxes an automation peer that reports `IsPassword` and returns no value. At
minimum, override the exposed text on `TextBox.secret`. File it upstream with Avalonia as well. Check
the desktop's UIA peer for the same leak.
### H2 · The passphrase survives enrolment and pre-fills the unlock box
- **What:** right after the recovery code, the unlock screen already holds the passphrase (16 dots;
[05](android/05-unlock-prefilled.png)) beside "Unlock with the passphrase you just chose."
- **Why:** `ConfirmRecoveryCode` clears `RecoveryCode` from memory on purpose ("keeping it in a view
model … would undo that"), but `Passphrase` is never cleared after enrolment. Combined with H1, it's
readable from the a11y tree on that screen. It also skips the one moment that proves the user
remembers the passphrase.
- **Fix:** clear `Passphrase` and its confirmation when enrolment completes, as the recovery code is
cleared.
### H3 · A hardware Enter that submits the connect sheet leaves the window "not responding"
- **What:** I typed the password and pressed Enter (an injected `KEYCODE_ENTER`, which is what a
Bluetooth or DeX keyboard sends). The connection started, but Android reported the window
unresponsive. The key-up was never acknowledged (`InputDispatcher: Waited 5009ms for
KeyEvent … ENTER … action=UP`). The ANR dialog then kept coming back
([12](android/12-anr.png)) until the process was killed. Meanwhile the main thread was idle in
`Looper.pollOnce`: it wasn't busy, the event was simply lost.
- **Not affected:** tapping **CONNECT**, and the soft keyboard's ✓ (which sends an editor action, not a
key event). Both connected cleanly.
- **Likely cause:** focus moves to the terminal's native renderer (`Platform/RendererFocus.cs`) while
the Enter key-up is still in flight.
- **Repro:** host row → type password → hardware Enter.
- **Confidence:** reproduced once, and an accessibility dump was running at the time. Confirm on a real
device with a keyboard.
### H4 · Screen-reader names are type names across the app
The a11y tree announces these:
| Control | Announced as |
|---|---|
| Bottom nav (Hosts / Connections / Settings) | `Avalonia.Controls.Panel` ×3 |
| Add-sheet rows, Settings rows, session chip | `Avalonia.Controls.StackPanel` / `Avalonia.Controls.Grid` |
| Keychain filter chips | `Avalonia.Controls.StackPanel` ×5 |
| Host list rows | `DodoSSH.Client.Shell.ViewModels.HostRowViewModel` |
| Host editor dropdowns | `AuthenticationChoice { Kind = Typed, EntityId = , … }`, `GroupChoice { … }` |
| Vault row | `VaultRowViewModel { VaultId = 01a0fe77-… }` (leaks an internal id) |
| FAB / header add | `+` (no name) |
| Decorative glyphs | `⌕ ▤ ⌗ ⚙ ⚿ ❯ ⇅ ◳ ≡ ◎ ›` exposed as text |
There are two further problems:
- **Hidden content:** the hosts list stays in the tree under the full-screen host editor, so a
screen-reader user can wander into a screen they can't see.
- **Leftover desktop controls:** the desktop scrollbar's "Line up / Page down" buttons are exposed too.
**Fix:** set `AutomationProperties.Name` on every button whose content isn't a string. Give row
templates a name, or override `ToString` on the row view models. Mark glyph `TextBlock`s
`AutomationProperties.AccessibilityView="Raw"`. Hide covered content from the tree while an editor
covers it.
### H5 · Copying the recovery code isn't marked sensitive
`CopyRecoveryCode` goes through Avalonia's plain `SetTextAsync` (`App.axaml.cs:187`). On Android 13+,
the system shows a clipboard preview of the copied text. That overlay is system UI, so this screen's
`FLAG_SECURE` doesn't cover it, and Gboard keeps the text in its clipboard history. Use a native
`ClipData` with `ClipDescription.EXTRA_IS_SENSITIVE` for any secret the app copies. (Read from code;
not observed, because the screen can't be captured.)
---
## Medium — visual system
### M1 · The accent glow is clipped to a square on every primary button and the FAB
Each gradient button sits on a slightly lighter rectangle, and its corners show past the rounded shape:
- [z-glow-button](android/z-glow-button.png): SIGN IN, CREATE THE VAULT, UNLOCK, CONNECT, SAVE, ADD
FILES;
- [z-glow-fab](android/z-glow-fab.png): the round FAB sits inside a visible square.
`AccentGlow` (`0 0 22 0`) is set on the template's `ContentPresenter`
(`Phone.axaml` `Button.primary` / `Button.fab /template/ ContentPresenter`) and gets clipped to the
button's own bounds. The desktop's `Button.accent` uses the same pattern, so check it there too. Fix:
draw the glow from an unclipped wrapper (a `Border` with margin), or drop it on the phone.
### M2 · Body copy is monospace, against the shared-typeface decision
`docs/design-import-gaps.md` ("The phone catches up") records that the phone shares the desktop's
faces. On the desktop, prose (`hint`, `heading`, the settings titles and captions) is Montserrat, and
mono is kept for identifiers (`.mono`) and labels (`.label`). On the phone, `TextBlock.body`, `.title`
and `.detail` all still set `MonoFont` (`Phone.axaml:661–680`). So every paragraph, title and caption is
JetBrains Mono, and only screen headers (Personal, Settings, New host) are Montserrat. The result is
long, wide-set paragraphs that are tiring to read ([03](android/03-enrol.png),
[16](android/16-settings.png), [19](android/19-sftp.png)).
Keep mono for labels, buttons (the recorded ALL-CAPS-mono convention), addresses and code. Move
`.body`, `.title` and `.detail` to the default sans.
### M3 · Fluent's selection fill leaks into the host list
After a connect, the tapped row stays lit in `#3d486b` ([15](android/15-selected-row.png)). That
slate-blue isn't in `Palette.axaml`, which says selection is `Active` `#1E1E2C`. The comment at
`HostsScreen.axaml:360` says the selection fill was deliberately removed ("a lit row would be a mark
with no action behind it"). But only the custom `Border.hostrow.chosen` style was changed. Fluent's
`ListBoxItem:selected /template/ ContentPresenter` background was never overridden.
### M4 · Disabled secondary buttons are heavier than enabled ones
`Button.secondary` has no `:disabled` style, so Fluent's `#3c3c43` slab shows through. SFTP's disabled
**SAVE FILE** is the most prominent button in its row ([z-sftp-actions](android/z-sftp-actions.png)).
Mirror `Button.primary:disabled`: a `Raised` fill and `TextFaint` text.
### M5 · Unstyled Fluent controls among styled ones
- **Dropdowns:** `ComboBox` keeps Fluent's ~4 px radius, bright white border and black fill beside
10 px, softly bordered fields (host editor, SFTP host picker; [08](android/08-host-editor.png)).
- **Checkboxes:** the default `CheckBox`, with its label sitting below the box's centre (relay, remember
password, press-Enter; [10](android/10-connect-sheet.png)).
- **Focused fields:** the fill switches from `Field` to pure `#000`.
- **Scrollbars:** desktop scrollbars are always visible. On Keychain, the horizontal bar is drawn over
the bottom of the filter chips ([17](android/17-keychain.png)). Android expects auto-hiding overlay
bars.
- **Stuck hover:** after a tap, Vaults' ↻ keeps a grey hover square. Touch has no hover, so
`:pointerover` styles need a touch-safe variant.
### M6 · The terminal's colour and font have drifted from the palette
`WebAssets/terminal.css` says its `#171a26` background "is `TerminalSurface` in Palette.axaml". The
palette now says `#18161F`. The terminal therefore reads navy, against the app's purple-black
([14](android/14-terminal.png)). Its font stack starts at `ui-monospace` and never names the embedded
JetBrains Mono, so on Android the WebView falls back to the system mono. That makes two different
monospace faces on one screen.
### M7 · Iconography
- **Glyphs, not icons:** the phone uses Unicode glyphs (`⚿ ❯ ⇅ ◳ ≡ ◎ ⚙ ▤ ⌗ ⌕`), even though Material
Icons is embedded and the desktop uses it. Weights and optical sizes vary (the heavy purple `❯` beside
hairline `◳`; [16](android/16-settings.png)).
- **Reused meanings:** ⚙ is both the Settings tab and the Preferences row, and ⚿ is both Keychain and
"Lock the keychain".
- **Splash:** the splash shows a flat purple circle, while the app's own icon is a rounded square with
the gradient.
---
## Medium — information architecture and flows
### M8 · "Settings" holds the product
Keychain, Snippets, SFTP, S3, Logs and Vaults all sit under the ⚙ **Settings** tab, beside
Preferences and Lock ([16](android/16-settings.png)). File transfer and snippets are working tools, not
settings. Options:
- rename the tab ("More" or "Tools") and keep Preferences as one row in it;
- or promote SFTP to the bottom nav. Its icon is already ⇅.
### M9 · Keychain can't add anything
The empty state says "Add an SSH key or a password to stop typing one" ([17](android/17-keychain.png)).
The screen has no add control: no FAB, no header +, nothing in the a11y tree. Passwords can only be
created from the host editor's "+ NEW CREDENTIAL", and no phone path adds an SSH key. If keys are
desktop-only, the empty state should say so.
### M10 · Three ways to add, two ways to edit
- **Add:** Hosts has a FAB, Snippets a header "+", Keychain nothing, and the terminal's header "+"
means "new session".
- **Edit:** the host editor is a full-screen page with ← and ✓ ([08](android/08-host-editor.png)). The
snippet editor is an inline card with **SAVE/CANCEL**, and the header "+" stays live while it's open
([18](android/18-snippet-editor.png)).
Pick one pattern for each.
### M11 · SFTP
- **Action block:** it covers more than half the screen (seven buttons, a permanent help paragraph and a
status line), so only about four file rows are visible ([19](android/19-sftp.png)).
- **No selection:** RENAME, MOVE, PERMS and DELETE are enabled with nothing selected. Tapping one only
writes "Choose something on the host to rename." into the status line at the bottom.
- **DELETE:** neutral-styled here, while delete is danger-red on hosts and the keychain.
- **Directory rows:** marked only by a tiny purple ▸, with no folder icon.
- **Password again:** it's asked for again although a shell to the same host is already open.
- **S3:** blocked while an SFTP session is open. Tapping S3 bounces to SFTP, with "An SFTP session is
open. Close it before opening a bucket." at the bottom ([20](android/20-s3-bounced.png)).
Suggestion: a contextual action bar that appears with a selection, help moved behind an ⓘ, and the
listing given the screen.
### M12 · Bottom sheets
- **Partial scrim:** the scrim covers only the content area. The header (with **LOCK**) and the bottom
nav stay bright and tappable under every sheet ([07](android/07-add-sheet.png),
[10](android/10-connect-sheet.png)).
- **Decorative handle:** it has a drag handle, but swipe-down doesn't dismiss.
- **Button order:** the primary action is on the left (CONNECT | CANCEL, SAVE | CANCEL). Android puts
the confirming action on the right.
- **No autofocus:** the connect sheet's password field isn't focused on open, so it takes an extra tap.
- **Nav above keyboard:** the bottom nav rides up above the keyboard and eats the sheet's space
([13 before trust](android/13-host-key.png)).
### M13 · Sign-in and enrolment
- **Full browser:** sign-in opens a full Chrome tab (`ChromeTabbedActivity`), not a Custom Tab, so the
user leaves the app. Chrome's own first-run prompts then land on top.
- **Unannounced second sign-in:** step 2 asks for a passphrase, and **CREATE THE VAULT** then sends the
user to the identity provider's login form a second time. That's the deliberate `prompt=login` key
binding (`OidcClient.AuthorizeKeyBindingAsync`), but no copy warns that it's coming. Say "you'll
confirm your identity once more" on the passphrase screen.
- **Blank resume:** after the browser returns, the app showed a blank `#05050a` canvas for 5–10 s with
no progress indicator. Every shell state has a screen, so this looks like surface re-creation on
resume (Debug + emulator). Confirm on Release, and keep a visible state on screen while it happens.
- **Step labels:** start at "STEP 2 OF 3"; step 1 is never labelled.
### M14 · The global status line leaks across screens
A single `StatusMessage` follows the user around:
- "Unlocked 'Personal'." in the middle of Preferences ([21](android/21-preferences.png));
- "Synchronised: 2 in, 2 out." after adding one host. `Describe` counts `Pulled`/`Pushed`, which include
the app's own log entries, while `PulledItems`/`PushedItems` already exist for this;
- "audit-box asks for a password." repeated under the sheet that asks for it.
Scope messages to the screen that raised them, or use transient snackbars.
---
## Low — input, copy, polish
- **Keyboards:**
- the server URL field gets a text keyboard with contact suggestions, not the URL keyboard
([02](android/02-server-keyboard.png));
- the port field gets the full letter keyboard ([z-port-keyboard](android/z-port-keyboard.png));
- hostname and username get autocorrect suggestions.
Set `TextInputOptions.ContentType` (`Url`, `Digits`) and turn autocorrect off for those.
- **Placeholder-as-label:** name, hostname, username and notes in the host editor, the unlock field,
and the snippet fields have no labels, so each field's meaning disappears once typed in.
- **Terminology:**
- "CREATE THE VAULT", then "Creating your keychain";
- "vault passphrase" (placeholder) next to "Enter your keychain passphrase" on the same screen;
- "ENROLLMENT" (US spelling) in a British-English app ("Synchronised", "unopenable").
- **Fingerprint wrapping:** a single "I" wraps onto its own line. Chunk it, or size it to fit
([13](android/13-host-key.png)).
- **Address format:** "10.0.2.2 · 2222" on the host-key sheet, "10.0.2.2:2222" everywhere else.
- **Dates:** "10/2/2026 11:29 PM" in Logs ([23](android/23-logs.png)), "2026-10-02 21:04" in SFTP.
- **Notification permission:** Android's prompt appears without explanation at the first connect.
Explain the keep-alive notification first.
- **Terminal width:** the default size gives about 37 columns, so `ls -la` wraps every line.
- **Empty and loading states:**
- Hosts' empty state is one grey line at the top left ([06](android/06-hosts-empty.png));
- Vaults first rendered an empty list with no loading indicator;
- the keychain shows both "0 items" and an empty-state sentence.
- **Connecting card:** **CLOSE THIS TAB** and **SHOW LOGS** are indented past the card's left edge
([11](android/11-connecting.png)).
- **Host-key context:** the sheet opened over the Quick Connect form, not over the connection progress
it belongs to.
- **Disabled fields:** on the enrolment busy state they turn a flat mid-grey
([04](android/04-enrol-busy.png)), and the keyboard stays up.
- **Fingerprint setting:** "Unlock with a fingerprint" in Preferences is drawn as a bare circle; it reads
as a radio button, not a switch.
## Not verified here
- **System font size:** whether the app honours it. No `FontScale` handling exists in the phone code,
and Avalonia scales by density, so text likely ignores the setting. I didn't change device settings
to confirm.
- **Landscape and tablet:** an `IsWide` layout exists but wasn't exercised.
- **TalkBack:** not run end to end. The H4 findings come from the tree TalkBack reads.
## Environment notes for a re-run
- **Keycloak port:** 18080 was taken by another project's container, so Keycloak ran on **18180** via a
compose override, with `Oidc__Authority=http://127.0.0.1:18180/realms/dodossh` and
`adb reverse tcp:18180`.
- **`adb root`** restarts adbd and drops `adb reverse`. Re-add both forwards afterwards, or the app
shows OFFLINE.
- **The sshd** was a throwaway `linuxserver/openssh-server` container (`dodossh-audit-sshd`, port 2222,
user `dodo`).
- **Build:** `-p:RuntimeIdentifier=android-x64` adds an `android-x64` section to every
`packages.lock.json`. Restore those files afterwards.
+147
View File
@@ -0,0 +1,147 @@
# DodoSSH full sweep — 2026-10-02
## Fix status (same day)
Every fix below has a regression test. Where marked ✔✔, the test was also checked to fail with the fix taken out.
| Item | Fix |
|---|---|
| C1 ✔✔ | `LocalDirectory.ChildPath` cleans remote names and requires the resolved parent to be the target folder. It is used by every download path and by EDIT. `LocalEditor` opens a file that would run (PATHEXT plus a known list, and macOS launchers) with the type's own `edit` verb or with Notepad / `open -t`. On Linux, opening a `.desktop` file is refused. |
| C2 ✔✔ | Pinned key types are offered first. A key under an algorithm with no pin of its own is accepted only if its fingerprint matches one of the endpoint's pins (the same RSA key under `rsa-sha2-256` vs `-512`); otherwise it is a hard mismatch. Tested against the real OpenSSH container. |
| C3 ✔✔ | Complete, Revise and Park take the `PendingOperation` and act only while the row still holds that operation id. A later edit is rebased onto the version that was just accepted, so it pushes without a conflict. |
| C4 | Pull leaves out change entries the item has moved past, so a revision is never paired with newer ciphertext. This also stops re-sending an item's payload once per historical edit. |
| C5 | Deleting a team's last vault archives the team only when the owner does it. `TeamService.ArchiveAsync` now checks ownership itself. |
| C6 | `DeviceKeyNames`: the default profile keeps `DodoSSH.DeviceKey.v1`, and every other profile gets its own name. Sign-out deletes only its own key. A profile still sealed under the shared key keeps reading it. The same applies to the macOS keychain tag. |
| H1–H3 | The data plane reassembles messages up to 16 MiB. A frame that throws stays with its own session. Input writes are serialized per pump, so a slow write is never overtaken. |
| H4 | The host and bucket the user chose are remembered across reloads. A typed password is only sent to the host it was typed for. |
| H5 | Selecting a vault reads its people even while the screen is busy. A generation counter discards answers for an older selection. |
| H6 / U9 | Locking or signing out leaves Settings, and the Members panel closes with it. Escape closes the Members panel first. |
| H7 ✔✔ | The tracked user is reloaded after the out-of-band last-seen update. |
| H8 | `sync_state.pulled_types` (with a client migration) restarts the pull once when the synced type list changes. |
| H9 | `ActiveAccountRequirement` on both policies. A suspended account gets 403 `account-not-active`. |
| H10 | `DevicePinWindow` palette keys fixed, and it now uses `InitializeComponent`. `Load` never assigned the named fields, so the window could never have opened. Covered by a new layout test. |
| H11 ✔✔ | `IRemoteFileStore.CanWriteFromAnOffset`: false for buckets, so a retried upload starts over. |
| H12 | `ImportOutcome` carries refused hosts and the reason a batch stopped early, including a busy keychain. Both import screens say so. |
| U1–U4 | The host card's sync badge moved to the address line, so names show in full. Tag chips wrap and trim. Keychain names trim inside their column, with a tooltip. The Security caption wraps. |
Second round, on branch `claude/audit-followups`:
| Item | Fix |
|---|---|
| U5 ✔✔ | The closed vault's card takes the keyboard on launch and on each new card (server address, enrolment, passphrase). Enter signs in and enrols. |
| U6 ✔✔ | The host-key, delete and sign-out questions give the keyboard to their harmless answer (CANCEL / CLOSE, never TRUST or DELETE). Escape says no to each, and closes the hosts drawer. Tab stays inside the host-key card. |
| U7 ✔✔ | Arrow-key selection on the Hosts board is written back to the vault, from the list that has the keyboard only. An arrow on the board itself enters the cards. Enter on a card connects. |
| U8 | `DeviceUnlockWording`: Windows Hello, Touch ID, a TPM-checked device PIN or a fingerprint, according to the platform. |
| U10 | Session tab labels are capped at 200 px and trimmed, with the full label in a tooltip. |
| U11 | The keychain's VAULT column is an Auto column in a shared size group, so it takes no width when no row names a vault. The Tags page's always-empty column is gone. The snippet buttons appear only with a snippet chosen. |
| U12 ✔✔ | `TerminalTabViewModel.EndedAt`: the status-bar timer stops when the shell ends. |
| P1 (part) ✔✔ | A sync pass that moved nothing no longer reloads (and decrypts) the whole keychain. It restrings the ago-texts instead. Failed vaults still reload. The Clear-plus-Add rebuild itself is unchanged. |
| P3 | `ProbedDeviceKeyStore`: the TPM probe runs once per process, on the thread pool. |
| P5 | A pulled page is mirrored in one write, and the outbox is read once per page. Changes with a local edit waiting still go one at a time, in order. |
| P6 ✔✔ | `OpenShellAsync` makes its three round trips on the thread pool, honours cancellation, and closes a shell that arrives after the caller gave up. |
| Transfers ✔✔ | Lost wake-up fixed with a `pumping` flag cleared under the same lock that finds the queue empty (a stress test failed 2 of 2 runs before the fix). Cancel as a transfer finishes no longer throws. A drop or download refused while busy says so. |
| EF retry ✔✔ | `FreshAttempts.ExecuteFreshAsync`: a retried write transaction clears the tracker and reloads what it carries in. Before this, a lost commit made a team hand-over answer 204 with nothing changed. Tested with an interceptor that fails one commit. |
| Rejected delete ✔✔ | A delete of a create that never landed is complete when the server says it has no such item. A create that did land still behaves as before: the delete gives way, and the user is told. |
| Team archive race | Archive and team-vault creation take a per-team advisory lock. Archive re-counts the team's vaults, and creation re-checks the team is live. No race test. |
| Relay revocation ✔✔ | `RelayAccessWatch` re-checks a user's open relay sessions when their access changes and ends lost ones with 4403 / `AccessRevoked`. |
| Shell | `ToggleVaultAsync` catches a failure and redraws. `CrashLog` writes uncaught exceptions to `crash.log` in the profile (the process still exits). A lock pressed during an unlock now wins (✔✔). |
| Window chrome | `CaptionPresses` decides a double-click on the press, before `BeginMoveDrag`. Only a headless test covers it; it is not verified on a real Windows window. |
| P1 (rest) ✔✔ | `ReplaceableCollection.ReplaceAll`: the host, visible-host, section and bucket lists are swapped in whole and announced once, and each transfers picker refreshes only from its own list. |
| P2 (most) | The transfer queue and import lists are virtualised (✔✔: 2,000 of 2,000 rows were built before). The find box hands its text to the vault after a 150 ms pause in typing, and the view model still filters synchronously. |
| P2 (board) ✔✔ | `VirtualizingWrapPanel` builds only the host cards near the view. Measured headless, after warm-up: first layout with 300 hosts went from about 3 s to about 0.5 s, and with 1,000 from 8.3 s to 0.2 s. Clearing a filter went from 1–8 s to about 0.1–0.2 s. Arrow keys, End, the band and selection still work. |
| P4 ✔✔ | **Server:** `TombstoneCollector` runs every 6 h. It deletes tombstones past the 90-day retention together with their change entries, and compacts each item's superseded entries down to its newest. It then raises the vault's `SyncHorizon`, and a cursor from before it is refused. A cursor now carries the horizon it was issued under. Without that, the read from the beginning that a refused client starts was refused on its own second page and looped for ever on any vault larger than a page (✔✔: found by a client test, then reproduced in an API test). **Client** ("resync and prune", as chosen): a read from the beginning marks every item it reads, keeping the mark in the sync state because the read can span passes. When it finishes, it removes unmarked items of the synced types. It keeps items with an unsent change, and items pushed during the read. Such an edit is then sent as a create, as before, but now records a `RecreatedAfterCollection` conflict and is named in the sync message. The removed items are named too. New client cache migration `AddSweepMarks`. |
Not done, and why:
- **Live-run observations** (production as the default server URL, Keycloak on host port 18080): product decisions, not bugs.
Pre-existing flakiness: on untouched HEAD, 3 of 8 runs of `ShellFlowTests` each had one test fail: `TheCountsAPhoneShows_FollowEveryForwardsState`, `ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself` and `ReconnectingAHostThatWantsAPassword_AsksForOneRatherThanDiallingWithout`. These races are in the tests and predate this work.
How it was done:
- The desktop client ran on Windows with an isolated profile against a local stack: Postgres, Keycloak and the API on :5333.
- The real MainWindow was rendered headless through every screen and settings page, 39 captures at 1081×583 and 1440×900.
- Four parallel code audits covered: the Shell view models; SSH/terminal/transfer; the server and sync; the desktop views and platform code.
- The findings marked **✔ verified** were re-read in code by me, or seen in a screenshot. The rest come from the audits; each one was traced, but I did not check them again.
The live session could not get past OIDC sign-in. The default browser is Edge, which I can't type into, so the terminal, SFTP and host-key flows were reviewed in code only.
---
## Critical — data loss, security
| # | Finding | Where |
|---|---|---|
| C1 | **Path traversal on download / open-to-edit.** Remote names go straight into `Path.Combine(LocalPath, row.Name)`. SFTP only drops `.` and `..`, so on Windows a name with backslashes (`..\..\AppData\…\Startup\x.bat`) or a rooted name escapes the folder. S3 keys `../..` do the same on every OS. EDIT then opens the file with `UseShellExecute`, so a `.bat`/`.hta` *runs*. The Android head sanitises names; the desktop doesn't. ✔ verified | `TransfersViewModel.cs:2682,2698,2780,2784,3165`; `SshNetSftpSession.cs:105`; `ObjectKeys.cs:50`; `App.axaml.cs:380` |
| C2 | **Host-key pin bypass by changing the key algorithm.** Pins are looked up per algorithm. A MITM that offers ecdsa or rsa to a host pinned for ed25519 gets the "never seen before" first-use prompt instead of the mismatch hard block. ✔ verified | `SshNetConnectionFactory.cs:261-272` |
| C3 | **The outbox loses an edit made while a push is in flight.** `CompleteAsync` deletes the row by `Sequence` only, but `QueueAsync` folds a newer edit into that same row with a new `OperationId`. When the older push is acknowledged, the newer edit is deleted silently. ✔ verified | `OutboxStore.cs:239-250` vs `:71-97`; `SyncEngine.cs:554` |
| C4 | **Pull pairs historical revision numbers with the current ciphertext.** `Describe` sends `Version: change.Revision`, but the payload comes from the item's current row. The AAD binds the version, so the payload fails to decrypt. A concurrent offline edit gets parked as "Undecryptable". ✔ verified | `SyncService.cs:565,592` |
| C5 | **A team admin can archive the team (meant to be owner-only).** If an admin deletes the last vault, `ArchiveAsync` runs with no role check and revokes every membership, the owner's included. | `VaultGrantEndpoints.cs:182,196`; `TeamService.cs:242-289` |
| C6 | **The shared TPM key name breaks other profiles.** `WindowsDeviceKeyStore` uses one CNG key name (`DodoSSH.DeviceKey.v1`) for the release and nightly profiles. Sign-out in one (`ForgetAsync`) deletes the key, and the other profile's device unlock stops working. This contradicts `ClientPaths.ForChannel`, which says the two installs are kept apart. ✔ verified | `WindowsDeviceKeyStore.cs:127,233-246` |
## High — broken behaviour
| # | Finding | Where |
|---|---|---|
| H1 | **Terminal input >64 KiB (a large paste or copy) is lost.** On a frame that doesn't fit the buffer, the loop `continue`s, and the tail is then dispatched as if it were a frame. ✔ verified | `TerminalDataPlane.cs:465-472` |
| H2 | **Keystrokes can race.** Input frames are written to `ShellStream` without awaiting and without a lock, and the base `FlushAsync` runs on the pool. A snippet RUN (text, then `\r`) over about 4 KiB can execute truncated. | `TerminalDataPlane.cs:505`; `SshNetConnectionFactory.cs:554-558` |
| H3 | **One resize on a closing shell tears down the renderer socket for every tab** (`ObjectDisposedException` escapes the receive loop). | `TerminalDataPlane.cs:516-520` |
| H4 | **The SFTP host picker resets to the first host on every sync pass**, about once a minute. Selection is lost on `Hosts.Clear()`, so CONNECT can dial host[0] with the password typed for host X. The same Clear-then-Add pattern resets the Known Hosts and Snippets selection and closes the snippet delete card. | `TransfersViewModel.cs:3905,3923-3950`; `KnownHostsViewModel.cs:264`; `SnippetsViewModel.cs:689` |
| H5 | **The Vaults Members panel can show vault A's members under vault B**, and SHARE / REVOKE then act on that mix. | `VaultsViewModel.cs:1646,1698-1765` |
| H6 | **Locking or signing out from Settings leaves a window with no title bar** (no close, minimize or drag). Unlock then returns to Settings. ✔ verified by screenshot | `MainWindowViewModel.cs:3254,3400`; see `locked-from-settings-no-titlebar.png` |
| H7 | **Enrollment returns 500 if last-seen is more than an hour old.** `ExecuteUpdate` changes `xmin` under a tracked entity, and the `SaveChanges` that follows throws a concurrency exception. | `CurrentUserContext.cs:92,120`; `EnrollmentService.cs:177` |
| H8 | **A new synced type never back-fills.** A filtered pull moves the cursor past types the client didn't request, so after an upgrade adds a type (SpelunkKey, ObjectStore), older changes of that type never arrive. | `SyncService.cs:50,69`; `SyncEngine.cs:145` |
| H9 | **Suspended users keep full access.** `UserStatus` is checked nowhere on the request path. | `CurrentUserContext.cs:126` |
| H10 | **`DevicePinWindow` references `Surface` and `MutedText`, which don't exist**, so Linux TPM-PIN register/unlock should throw on open. ✔ verified | `DevicePinWindow.axaml:7,35` |
| H11 | **A failed S3 upload commits a truncated part, and every retry then fails.** | `S3UploadStream.cs:126-146` |
| H12 | **Import screens report success when the vault skipped or failed the import** (busy, or a mid-batch exception). | `ImportViewModel.cs:374`; `HetznerImportViewModel.cs:561` |
## UI issues seen in the running app
| # | Finding | Where |
|---|---|---|
| U1 | **Host card labels are cut to 3-4 characters even at 1440px.** "bastion" shows as "bast…", and k8s-worker-a / -b both show as "k8s…", as do web-prod-01 / -02. The label is the primary identifier, and the "not synced" badge takes most of its width. ✔ `hosts-truncated-labels.png` | `HostsScreen.axaml` card template |
| U2 | **Tag chips overflow the host card edge.** The WrapPanel sits inside a horizontal StackPanel, so it never wraps. ✔ | `HostsScreen.axaml:737-774` |
| U3 | **Keychain table: long names overprint the TYPE / USED BY columns.** `TextTrimming` has no effect inside a horizontal StackPanel. The same pattern appears in Logs, SessionSidebar, Snippets, NavRail and Transfers. ✔ `keychain-overlap.png` | `KeychainScreen.axaml:294` et al. |
| U4 | **Security page: the "End-to-end encrypted" caption runs past the card edge.** ✔ `security-text-clipped.png` | `SettingsSecurityPage.axaml:49-60` |
| U5 | **Enter does nothing in the server URL box** (verified live). The same applies to the enrollment passphrase boxes. The passphrase box is not focused on launch. | `MainWindow.axaml:385` |
| U6 | **Overlay cards (host key, delete, sign-out, host editor) have no Enter/Escape and don't take focus**, and Tab walks the page behind them. | `HostKeyCard.axaml` etc. |
| U7 | **Hosts board: arrow-key selection isn't written back to the view model**, so the drawer's Connect / Delete act on the previously clicked host. There is no Enter-to-connect. | `HostsScreen.axaml:573` |
| U8 | **Windows-only wording on macOS/Linux:** "USE WINDOWS HELLO", "Waiting for Windows…". | `UnlockCard.axaml:51`; `MainWindowViewModel.GestureWait` |
| U9 | **Escape over the Members panel leaves Settings and leaves the panel open** for the next visit. | `MainWindow.axaml.cs:242` |
| U10 | **Session tab labels are unbounded.** One long label pushes "+" off the strip. | `SessionTabRow.axaml:89` |
| U11 | **A VAULT column is drawn and empty** with a single vault (Keychain, Tags). The snippets list footer shows Edit / Move / Delete with nothing selected. | screenshots |
| U12 | **The status-bar session timer keeps counting after a shell has ended.** | `MainWindowViewModel.cs:4979` |
## Performance
| # | Finding | Where |
|---|---|---|
| P1 | **Every sync pass, about once a minute, decrypts the entire keychain even when nothing changed**, then rebuilds each list with Clear + Add. That is O(n²) notification fan-out: with 300 hosts, roughly 45k collection notifications per pass. | `VaultViewModel.cs:6375,4709`; `MainWindowViewModel.cs:4549` |
| P2 | **The Hosts board is not virtualised and is rebuilt on every filter keystroke.** The transfer queue (one row per file in a folder download) and the import lists are not virtualised either. | `HostsScreen.axaml:448-576`; `TransfersScreen.axaml:1193` |
| P3 | **The TPM probe (create and delete an RSA-2048 key) runs synchronously on the UI thread** at startup and after every unlock. | `WindowsDeviceKeyStore.cs:152`; `MainWindowViewModel.cs:2594,2970` |
| P4 | **Pull re-sends the full current payload once per historical change.** There is no change-log compaction and no tombstone GC, even though ADR 0003 promises 90-day GC. | `SyncService.cs:45-56,565` |
| P5 | **The client applies each pulled change in 2 or more separate SQLite transactions.** | `SyncEngine.cs:157-169,303` |
| P6 | **`OpenShellAsync` does blocking network round trips wrapped in `Task.FromResult`** and ignores cancellation. | `SshNetConnectionFactory.cs:401-418` |
## Lower / plausible
- **Transfers:**
- A wake-up can be lost, leaving a transfer queued forever (`FileTransferQueue.cs:452-505`).
- Cancel at the moment a transfer finishes can throw `ObjectDisposedException`.
- Drops and downloads are ignored silently while busy (`TransfersViewModel.cs:2272`).
- **Sync and server:**
- The EF retry strategy re-runs a push with stale tracked state (`Persistence.cs:38`).
- Deleting a create that never landed leaves a permanent "Rejected" conflict.
- A team can be archived while a vault is being created in it.
- Relay sessions survive removal from the team, for up to 12 hours.
- **Shell:**
- Lock can race an unlock that is still loading.
- `ToggleVaultAsync` has no try/catch, and there is no global `UnhandledException` handler.
- **Window chrome:** double-click to maximise may be swallowed by `BeginMoveDrag`.
## Observations from the live run
- **The first-launch default is the production server, and sign-in completes without a credential prompt.** `DefaultServerUrl = "https://ssh.dodotech.cloud"` is pre-filled. With a live browser SSO session, one press of SIGN IN on a brand-new profile completed sign-in with no password typed, and wrote the account's wrapped key and vault list into the new profile. For a self-hosted product, an empty field with a placeholder is the safer default.
- **The local dev compose hard-codes host port 18080 for Keycloak**, which collides with other projects on this machine.
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+9 -1
View File
@@ -68,7 +68,15 @@ the Npgsql connection string** — the default; do not enable multiplexing.
visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
- Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be
able to read tombstones, which is the one place that legitimately bypasses the soft-delete
query filter — guarded by an explicit permission check.
query filter — guarded by an explicit permission check. **The collection has since been built**
(`TombstoneCollector`), and it costs a machine away for longer than the retention its news of
those deletions. So a collection raises the vault's sync horizon, and a cursor from before it is
refused. The client then reads from the beginning, and when that read finishes it removes the
items it held that the read never mentioned. An item with an unsent local edit is kept: the
push finds the server without it, puts it back, and records a `RecreatedAfterCollection`
conflict. A cursor carries the horizon it was issued under. Without that, the read from the
beginning would be refused on its own early pages, which sit behind the horizon whenever an
older item survives.
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
Without it this ADR's central bug is invisible until production.
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
@@ -0,0 +1,24 @@
using DodoSSH.Api.Features.Relay;
namespace DodoSSH.Api.Features.Events;
/// <summary>
/// Announces a change to everything on this node that acts on one: the event sockets, and the relay.
/// </summary>
/// <remarks>
/// What the write paths are given as their <see cref="IVaultEventPublisher"/>. They announce a change once and
/// do not know who listens, which is the point: the relay learning about removals from teams did not have to
/// touch any of the places a removal happens.
/// </remarks>
internal sealed class VaultEventFanOut(VaultEventHub hub, RelayAccessWatch relay) : IVaultEventPublisher
{
/// <inheritdoc />
public void VaultChanged(Guid vaultId, long sequence) => hub.VaultChanged(vaultId, sequence);
/// <inheritdoc />
public void VaultAccessChanged(Guid userId)
{
hub.VaultAccessChanged(userId);
_ = relay.AccessChangedAsync(userId);
}
}
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using System.Text.Json;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using DodoSSH.Domain;
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
byte[] fingerprint,
CancellationToken cancellationToken)
{
var strategy = database.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
return await database.ExecuteFreshAsync([user], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
@@ -0,0 +1,62 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Domain.Authorization;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Ends a user's relay sessions to vaults they can no longer reach, when their access changes.
/// </summary>
/// <remarks>
/// <para>
/// A relay session is authorised once, when its ticket is issued, and used to run on for as long as it
/// carried bytes — up to the maximum lifetime, twelve hours by default. Removing somebody from a team took
/// their keys and their vault list away at once, and left any shell they had open through the relay running
/// into the team's machines until it went idle.
/// </para>
/// <para>
/// Told by the same announcement that tells their event socket to re-read its vaults — see
/// <see cref="Events.IVaultEventPublisher.VaultAccessChanged"/> — and it asks the same question the ticket
/// endpoint asked, against the database as it now stands. Off the announcing request, because that request
/// has already committed and answered, and a slow re-check must not hold it.
/// </para>
/// </remarks>
internal sealed class RelayAccessWatch(
RelaySessionRegistry sessions,
IServiceScopeFactory scopes,
ILogger<RelayAccessWatch> logger)
{
/// <summary>Re-checks every session the user has open, in the background.</summary>
/// <returns>The re-check, for a caller that wants to wait for it; the announcement does not.</returns>
internal Task AccessChangedAsync(Guid userId)
{
var open = sessions.SessionsOf(userId);
return open.Count == 0 ? Task.CompletedTask : Task.Run(() => RecheckAsync(userId, open));
}
private async Task RecheckAsync(Guid userId, IReadOnlyList<RelaySessionRegistry.Lease> open)
{
try
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var access = scope.ServiceProvider.GetRequiredService<IVaultAccessService>();
foreach (var lease in open)
{
var resolved = await access.ResolveAsync(userId, lease.VaultId, CancellationToken.None)
.ConfigureAwait(false);
if (!resolved.Granted || !resolved.Permissions.HasFlag(PermissionFlags.Connect))
{
lease.Revoke();
}
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
RelayLog.AccessRecheckFailed(logger, userId, exception);
}
}
}
@@ -83,7 +83,7 @@ internal sealed class RelayConnectEndpoint(
return;
}
var lease = sessions.TryAdmit(ticket.UserId);
var lease = sessions.TryAdmit(ticket.UserId, ticket.VaultId);
try
{
@@ -113,7 +113,7 @@ internal sealed class RelayConnectEndpoint(
return;
}
await RelayAsync(socket, ticket, ct).ConfigureAwait(false);
await RelayAsync(socket, ticket, lease.Revoked, ct).ConfigureAwait(false);
}
finally
{
@@ -239,7 +239,11 @@ internal sealed class RelayConnectEndpoint(
}
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
private async Task RelayAsync(WebSocket socket, RelayTicket ticket, CancellationToken requestAborted)
private async Task RelayAsync(
WebSocket socket,
RelayTicket ticket,
CancellationToken revoked,
CancellationToken requestAborted)
{
var started = clock.GetUtcNow();
@@ -269,7 +273,7 @@ internal sealed class RelayConnectEndpoint(
{
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
ending = await PumpAsync(socket, target, counters, started, requestAborted).ConfigureAwait(false);
ending = await PumpAsync(socket, target, counters, started, revoked, requestAborted).ConfigureAwait(false);
}
RelayLog.SessionClosed(
@@ -338,13 +342,14 @@ internal sealed class RelayConnectEndpoint(
Stream target,
Counters counters,
DateTimeOffset started,
CancellationToken revoked,
CancellationToken requestAborted)
{
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
var minding = MindAsync(counters, started, pump.Token);
var minding = MindAsync(counters, started, revoked, pump.Token);
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
var ending = await first.ConfigureAwait(false) ?? new Ending(
@@ -512,7 +517,11 @@ internal sealed class RelayConnectEndpoint(
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
/// </para>
/// </remarks>
private async Task<Ending?> MindAsync(Counters counters, DateTimeOffset started, CancellationToken cancellationToken)
private async Task<Ending?> MindAsync(
Counters counters,
DateTimeOffset started,
CancellationToken revoked,
CancellationToken cancellationToken)
{
var settings = options.Value;
var hardDeadline = started + settings.MaxSessionDuration;
@@ -527,27 +536,11 @@ internal sealed class RelayConnectEndpoint(
drainDeadline = now + settings.DrainTimeout;
}
if (now >= hardDeadline)
{
return new Ending(
RelayCloseReason.DurationLimit,
RelayProtocol.DurationLimitCloseCode,
"This relay session reached its maximum lifetime.");
}
if (drainDeadline is { } drain && now >= drain)
{
return ShuttingDown;
}
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
if (now >= idleDeadline)
if (Due(now, revoked.IsCancellationRequested, hardDeadline, drainDeadline, idleDeadline) is { } ending)
{
return new Ending(
RelayCloseReason.IdleTimeout,
RelayProtocol.IdleTimeoutCloseCode,
"This relay session carried nothing for the idle timeout.");
return ending;
}
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
@@ -555,8 +548,8 @@ internal sealed class RelayConnectEndpoint(
// Woken early by the start of a shutdown, so the drain is measured from when it began rather
// than from whenever the idle clock next came round.
using var wake = drainDeadline is null
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.ApplicationStopping)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked, lifetime.ApplicationStopping)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked);
try
{
@@ -564,7 +557,7 @@ internal sealed class RelayConnectEndpoint(
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The shutdown began. Round again to start the drain.
// The shutdown began, or access was withdrawn. Round again to act on whichever it was.
}
catch (OperationCanceledException)
{
@@ -575,6 +568,50 @@ internal sealed class RelayConnectEndpoint(
return null;
}
/// <summary>Whichever reason to end the session has come due, or null while none has.</summary>
/// <remarks>
/// Revocation first, because it is the one reason here that is about the user rather than the session:
/// they were removed from the team, or the vault was taken from them, after this session was admitted.
/// It used to run on for as long as it carried bytes, up to the maximum lifetime.
/// </remarks>
private static Ending? Due(
DateTimeOffset now,
bool revoked,
DateTimeOffset hardDeadline,
DateTimeOffset? drainDeadline,
DateTimeOffset idleDeadline)
{
if (revoked)
{
return AccessRevoked;
}
if (now >= hardDeadline)
{
return new Ending(
RelayCloseReason.DurationLimit,
RelayProtocol.DurationLimitCloseCode,
"This relay session reached its maximum lifetime.");
}
if (drainDeadline is { } drain && now >= drain)
{
return ShuttingDown;
}
return now >= idleDeadline
? new Ending(
RelayCloseReason.IdleTimeout,
RelayProtocol.IdleTimeoutCloseCode,
"This relay session carried nothing for the idle timeout.")
: null;
}
private static Ending AccessRevoked { get; } = new(
RelayCloseReason.AccessRevoked,
RelayProtocol.AccessRevokedCloseCode,
"You no longer have access to this host.");
private static Ending ClientGone { get; } =
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
@@ -91,4 +91,14 @@ internal static partial class RelayLog
Level = LogLevel.Warning,
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
/// <remarks>
/// A warning: the sessions it was checking stay open, which is how they all behaved before the check
/// existed, and each still ends at its idle timeout or its lifetime.
/// </remarks>
[LoggerMessage(
EventId = 2309,
Level = LogLevel.Warning,
Message = "Re-checking relay access for user {UserId} after an access change failed.")]
internal static partial void AccessRecheckFailed(ILogger logger, Guid userId, Exception exception);
}
@@ -22,6 +22,7 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
{
private readonly Lock gate = new();
private readonly Dictionary<Guid, int> perUser = [];
private readonly HashSet<Lease> open = [];
private int total;
/// <summary>How many sessions are open on this node.</summary>
@@ -55,7 +56,16 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
/// <summary>Admits a session, or answers null when a limit is met.</summary>
/// <returns>A lease that gives the place back when disposed.</returns>
internal Lease? TryAdmit(Guid userId)
/// <summary>The sessions a user has open now, for re-checking their access.</summary>
internal IReadOnlyList<Lease> SessionsOf(Guid userId)
{
lock (gate)
{
return [.. open.Where(lease => lease.UserId == userId)];
}
}
internal Lease? TryAdmit(Guid userId, Guid vaultId)
{
var settings = options.Value;
@@ -71,14 +81,18 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
perUser[userId] = held + 1;
total++;
return new Lease(this, userId);
var lease = new Lease(this, userId, vaultId);
open.Add(lease);
return lease;
}
}
private void Release(Guid userId)
private void Release(Lease lease, Guid userId)
{
lock (gate)
{
open.Remove(lease);
total--;
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
@@ -95,16 +109,41 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
}
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
internal sealed class Lease(RelaySessionRegistry registry, Guid userId) : IDisposable
internal sealed class Lease(RelaySessionRegistry registry, Guid userId, Guid vaultId) : IDisposable
{
private readonly CancellationTokenSource revoked = new();
private int released;
/// <summary>Whose session this is.</summary>
internal Guid UserId { get; } = userId;
/// <summary>The vault the ticket's host is filed in, which is what the session's access hangs on.</summary>
internal Guid VaultId { get; } = vaultId;
/// <summary>Cancelled when the session's user loses access to <see cref="VaultId"/>.</summary>
internal CancellationToken Revoked => revoked.Token;
/// <summary>Ends the session, because its user can no longer reach the vault it was opened for.</summary>
internal void Revoke()
{
try
{
revoked.Cancel();
}
catch (ObjectDisposedException)
{
// Already over.
}
}
/// <inheritdoc />
public void Dispose()
{
if (Interlocked.Exchange(ref released, 1) == 0)
{
registry.Release(userId);
registry.Release(this, UserId);
revoked.Dispose();
}
}
}
+12
View File
@@ -27,4 +27,16 @@ internal static partial class SyncLog
int appliedCount,
int operationCount,
Guid vaultId);
[LoggerMessage(
EventId = 2003,
Level = LogLevel.Information,
Message = "Collected {Count} expired tombstone(s) and superseded change-log entries.")]
internal static partial void Collected(ILogger logger, int count);
[LoggerMessage(
EventId = 2004,
Level = LogLevel.Warning,
Message = "Tombstone collection failed; it will run again at the next interval.")]
internal static partial void CollectionFailed(ILogger logger, Exception exception);
}
+69 -19
View File
@@ -26,12 +26,59 @@ internal sealed class SyncService(
CancellationToken cancellationToken)
{
var afterSequence = 0L;
var issuedUnder = 0L;
if (!string.IsNullOrEmpty(request.Cursor)
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence))
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence, out issuedUnder))
{
throw new InvalidCursorException();
}
// Read before the changes, for the cursor this read hands out; see the check after it.
var horizonBefore = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
var (changes, hasMore) = await ReadPageAsync(vault.Id, afterSequence, request, cancellationToken)
.ConfigureAwait(false);
// ◆ After the read, not before it. The collector deletes a tombstone's entries and raises the horizon
// in one transaction, so a read that already missed an entry is followed by a horizon that says so;
// checked first, the two could straddle a collection and the deletion would go unnoticed for good.
//
// A cursor behind the horizon is refused only if it was issued before it. One issued since belongs to
// a read from the beginning that started after the collection, and its early pages are behind the
// horizon because old, untouched items are; refusing those would send that read back to the start
// for ever. See SyncCursor.
var horizon = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
if (afterSequence > 0 && afterSequence < horizon && issuedUnder < horizon)
{
throw new InvalidCursorException();
}
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
// When nothing came back the cursor must not move, or a concurrent write landing between
// this read and the next would be skipped forever.
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
return new SyncPullResponse(
Changes: hydrated,
// The horizon from before the read, never the one after: a collection landing between the two
// could have taken an entry this page would otherwise have carried, and a cursor claiming the
// later horizon would vouch for a client that never heard of it. The earlier one costs at most
// a restart.
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence, horizonBefore),
HasMore: hasMore,
ServerTime: clock.GetUtcNow(),
CurrentKeyGeneration: (uint)vault.KeyGeneration);
}
/// <summary>The changes after a position, one page of them, and whether there are more.</summary>
private async Task<(List<VaultChange> Changes, bool HasMore)> ReadPageAsync(
Guid vaultId,
long afterSequence,
SyncPullRequest request,
CancellationToken cancellationToken)
{
var limit = Math.Clamp(
request.Limit ?? options.DefaultPullLimit,
1,
@@ -43,7 +90,7 @@ internal sealed class SyncService(
// One extra row, so "is there more" needs no second query.
var query = database.VaultChanges
.Where(c => c.VaultId == vault.Id && c.Sequence > afterSequence);
.Where(c => c.VaultId == vaultId && c.Sequence > afterSequence);
if (types is not null)
{
@@ -62,18 +109,7 @@ internal sealed class SyncService(
changes.RemoveAt(changes.Count - 1);
}
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
// When nothing came back the cursor must not move, or a concurrent write landing between
// this read and the next would be skipped forever.
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
return new SyncPullResponse(
Changes: hydrated,
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence),
HasMore: hasMore,
ServerTime: clock.GetUtcNow(),
CurrentKeyGeneration: (uint)vault.KeyGeneration);
return (changes, hasMore);
}
/// <summary>
@@ -100,9 +136,7 @@ internal sealed class SyncService(
{
ValidateBatchLimits(request);
var strategy = database.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
return await database.ExecuteFreshAsync([vault], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
@@ -624,14 +658,30 @@ internal sealed class SyncService(
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) =>
await database.VaultChanges
/// <remarks>
/// Never below the vault's horizon: collecting a tombstone can take the newest entries with it, and a
/// head reported lower than a cursor the client already holds would rewind it under the horizon and
/// cost a full resync for nothing.
/// </remarks>
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken)
{
var head = await database.VaultChanges
.Where(c => c.VaultId == vaultId)
.OrderByDescending(c => c.Sequence)
.Select(c => c.Sequence)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return Math.Max(head, await HorizonAsync(vaultId, cancellationToken).ConfigureAwait(false));
}
/// <summary>The vault's horizon as the database holds it now, not as the request loaded it.</summary>
private Task<long> HorizonAsync(Guid vaultId, CancellationToken cancellationToken) =>
database.Vaults
.Where(v => v.Id == vaultId)
.Select(v => v.SyncHorizon)
.SingleAsync(cancellationToken);
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
new(
operation.OperationId,
@@ -0,0 +1,198 @@
using DodoSSH.Api.Setup;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Sync;
/// <summary>
/// Removes tombstones past their retention, and change-log entries a later one has superseded.
/// </summary>
/// <remarks>
/// <para>
/// ADR 0003 promised this and nothing did it: every deletion stayed a row for ever, and every edit stayed a
/// change-log entry for ever, so the log grew without bound and a client starting from nothing walked every
/// edit ever made to read the items that exist.
/// </para>
/// <para>
/// <b>Superseded entries go at any time.</b> A pull returns entries after a cursor in order, so a client
/// before both an item's old entry and its new one reads the new one, and a client between them already has
/// the old one. Nothing reads history: a conflict resolves against the newest entry alone.
/// </para>
/// <para>
/// <b>Tombstones go after the retention</b>, and taking them is the one change here a client can notice: a
/// machine that last synced before the deletion would never hear of it. So each vault records the highest
/// sequence collected from it — <see cref="Vault.SyncHorizon"/> — and a pull from a cursor below that is
/// refused. The client answers by starting over, and a pull from nothing removes what the server no longer
/// has; see the client's <c>SyncEngine</c>.
/// </para>
/// <para>
/// Per vault under the same advisory lock a push takes, so a collection never interleaves with a write to
/// the vault — and the items are read again under it, so one restored since it was found is left alone.
/// </para>
/// </remarks>
internal sealed class TombstoneCollector(
DodoDbContext database,
IOptions<SyncOptions> syncOptions,
TimeProvider clock,
ILogger<TombstoneCollector> logger)
{
/// <summary>Collects what is due, once.</summary>
/// <returns>How many tombstones and superseded entries went.</returns>
internal async Task<int> RunOnceAsync(CancellationToken cancellationToken)
{
var before = clock.GetUtcNow() - TimeSpan.FromDays(syncOptions.Value.TombstoneRetentionDays);
var collected = 0;
collected += await CollectAsync(db => db.Hosts, ChangeEntityType.SshHost, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.SshKeys, ChangeEntityType.SshKey, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Credentials, ChangeEntityType.Credential, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.KnownHostKeys, ChangeEntityType.KnownHostKey, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.HostGroups, ChangeEntityType.HostGroup, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Tags, ChangeEntityType.Tag, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Snippets, ChangeEntityType.Snippet, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ConnectionLog, ChangeEntityType.ConnectionLogEntry, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ActivityLog, ChangeEntityType.ActivityLogEntry, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ObjectStores, ChangeEntityType.ObjectStore, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.SpelunkKeys, ChangeEntityType.SpelunkKey, before, cancellationToken).ConfigureAwait(false);
collected += await CompactAsync(cancellationToken).ConfigureAwait(false);
SyncLog.Collected(logger, collected);
return collected;
}
private async Task<int> CollectAsync<T>(
Func<DodoDbContext, DbSet<T>> table,
ChangeEntityType type,
DateTimeOffset before,
CancellationToken cancellationToken)
where T : class, IVaultItem
{
var due = await table(database)
.Where(item => item.DeletedAtUtc != null && item.DeletedAtUtc < before)
.Select(item => new { item.VaultId, item.Id })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var collected = 0;
foreach (var vault in due.GroupBy(item => item.VaultId))
{
var ids = vault.Select(item => item.Id).ToArray();
collected += await database.ExecuteFreshAsync(
[],
() => CollectFromVaultAsync(table, type, vault.Key, ids, before, cancellationToken))
.ConfigureAwait(false);
}
return collected;
}
private async Task<int> CollectFromVaultAsync<T>(
Func<DodoDbContext, DbSet<T>> table,
ChangeEntityType type,
Guid vaultId,
Guid[] ids,
DateTimeOffset before,
CancellationToken cancellationToken)
where T : class, IVaultItem
{
var transaction = await database.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
await database.Database
.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))", cancellationToken)
.ConfigureAwait(false);
// Read again under the lock: an item restored since it was found is no longer due.
var still = await table(database)
.Where(item => item.VaultId == vaultId
&& ids.Contains(item.Id)
&& item.DeletedAtUtc != null
&& item.DeletedAtUtc < before)
.Select(item => new { item.Id, item.ChangeSequence })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (still.Count == 0)
{
return 0;
}
var going = still.Select(item => item.Id).ToArray();
var horizon = still.Max(item => item.ChangeSequence);
await table(database)
.Where(item => item.VaultId == vaultId && going.Contains(item.Id))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await database.VaultChanges
.Where(change => change.VaultId == vaultId && change.EntityType == type && going.Contains(change.EntityId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await database.Vaults
.Where(v => v.Id == vaultId && v.SyncHorizon < horizon)
.ExecuteUpdateAsync(set => set.SetProperty(v => v.SyncHorizon, horizon), cancellationToken)
.ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return still.Count;
}
/// <summary>Removes every change-log entry a later entry for the same item has superseded.</summary>
private Task<int> CompactAsync(CancellationToken cancellationToken) =>
database.Database.ExecuteSqlRawAsync(
"DELETE FROM " + DodoDbContext.SchemaName + ".sync_change AS older "
+ "USING " + DodoDbContext.SchemaName + ".sync_change AS newer "
+ "WHERE newer.vault_id = older.vault_id "
+ "AND newer.entity_type = older.entity_type "
+ "AND newer.entity_id = older.entity_id "
+ "AND newer.sequence > older.sequence",
cancellationToken);
}
/// <summary>Runs the <see cref="TombstoneCollector"/> on a schedule, in a scope of its own each time.</summary>
internal sealed class TombstoneCollectionService(
IServiceScopeFactory scopes,
IOptions<SyncOptions> syncOptions,
TimeProvider clock,
ILogger<TombstoneCollectionService> logger) : BackgroundService
{
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var interval = TimeSpan.FromMinutes(syncOptions.Value.TombstoneCollectionIntervalMinutes);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(interval, clock, stoppingToken).ConfigureAwait(false);
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
await scope.ServiceProvider.GetRequiredService<TombstoneCollector>()
.RunOnceAsync(stoppingToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The next run tries again; nothing collected late is harmed by waiting.
SyncLog.CollectionFailed(logger, exception);
}
}
}
}
@@ -0,0 +1,27 @@
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// Serialises the writes that decide what a team owns, for the life of the transaction.
/// </summary>
/// <remarks>
/// <para>
/// Archiving a team refuses while it owns a vault, and creating a vault refuses on an archived team — but each
/// used to check outside any transaction the other could see, so the two could pass each other: the archive
/// counted no vaults, a create landed a moment later, and the team was archived with a live vault in it that
/// nobody could reach. Both now take this lock inside their transaction and check again under it.
/// </para>
/// <para>
/// The same advisory lock the sync push takes per vault, keyed apart so a team and a vault never share one.
/// Requires <c>Multiplexing=false</c> on the connection, as that one does.
/// </para>
/// </remarks>
internal static class TeamLock
{
internal static Task<int> AcquireAsync(DodoDbContext database, Guid teamId, CancellationToken cancellationToken) =>
database.Database.ExecuteSqlAsync(
$"SELECT pg_advisory_xact_lock(hashtextextended({"team:" + teamId.ToString()}, 0))",
cancellationToken);
}
+17 -19
View File
@@ -1,5 +1,6 @@
using System.Globalization;
using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -254,26 +255,27 @@ internal sealed class TeamService(
throw new InvalidOperationException("Only the owner of a team can archive it.");
}
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
if (vaultCount > 0)
{
throw new TeamNotEmptyException(
string.Create(
CultureInfo.InvariantCulture,
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
}
var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy();
var archived = await strategy.ExecuteAsync(async () =>
var archived = await database.ExecuteFreshAsync([team], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
// Counted under the lock a vault create takes, so one cannot land between this and the archive.
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
if (vaultCount > 0)
{
throw new TeamNotEmptyException(
string.Create(
CultureInfo.InvariantCulture,
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
}
var memberships = await database.TeamMemberships
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
@@ -343,9 +345,7 @@ internal sealed class TeamService(
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
.ConfigureAwait(false);
var strategy = database.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
await database.ExecuteFreshAsync([incoming, outgoing], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
@@ -724,9 +724,7 @@ internal sealed class TeamService(
}
var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy();
var revoked = await strategy.ExecuteAsync(async () =>
var revoked = await database.ExecuteFreshAsync([membership], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -76,19 +77,7 @@ internal sealed class VaultGrantService(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The pre-check covers the ordinary case; this is the race between two creates choosing
// the same id, which must not surface as a 500 about a constraint.
throw new TeamInvalidException(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
await WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
@@ -103,6 +92,55 @@ internal sealed class VaultGrantService(
RekeyRequired: false);
}
/// <summary>Writes the vault and its first grant, under the team's lock, in one transaction.</summary>
private Task WriteVaultAsync(
UserAccount user,
Team team,
CreateTeamVaultRequest request,
string name,
UserKey key,
CancellationToken cancellationToken) =>
database.ExecuteFreshAsync([team], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
// Under the lock an archive takes, and the team re-read under it: see TeamLock.
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
await RequireTeamStillLiveAsync(team.Id, cancellationToken).ConfigureAwait(false);
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The pre-check covers the ordinary case; this is the race between two creates choosing
// the same id, which must not surface as a 500 about a constraint.
throw new TeamInvalidException(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
});
/// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
{
var live = await database.Teams
.AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
.ConfigureAwait(false);
if (!live)
{
throw new TeamInvalidException("This team has been archived, so it can no longer own a new vault.");
}
}
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
private void AddVaultWithSelfGrant(
UserAccount user,
+9 -6
View File
@@ -41,13 +41,16 @@ builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
// write paths that announce a change need only the two methods that announce one, and should not gain
// a reference to connection management to get them.
// A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
// whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
// that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
// vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
builder.Services.AddSingleton<VaultEventHub>();
builder.Services.AddSingleton<IVaultEventPublisher>(
provider => provider.GetRequiredService<VaultEventHub>());
builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
// ADR 0003's tombstone collection, and the compaction of the change log that goes with it.
builder.Services.AddScoped<TombstoneCollector>();
builder.Services.AddHostedService<TombstoneCollectionService>();
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
builder.Services.AddDodoRelay();
+4
View File
@@ -289,6 +289,10 @@ public sealed class SyncOptions
[Range(1, 3650)]
public int TombstoneRetentionDays { get; set; } = 90;
/// <summary>How often the tombstone collector runs, in minutes.</summary>
[Range(1, 7 * 24 * 60)]
public int TombstoneCollectionIntervalMinutes { get; set; } = 6 * 60;
/// <summary>
/// Key used to sign sync cursors, base64.
/// </summary>
+72
View File
@@ -0,0 +1,72 @@
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Setup;
/// <summary>
/// Runs a write transaction under the retrying execution strategy so that a retry starts from the database
/// rather than from what the failed attempt left in the change tracker.
/// </summary>
/// <remarks>
/// <para>
/// <b>The strategy re-runs the delegate on the same context, and the context remembers the attempt that
/// failed.</b> When the failure is the commit itself — a connection dropped after <c>SaveChanges</c> had
/// already succeeded — every entity that attempt wrote is marked unchanged, holding values the database
/// rolled back. The retry then assigns the same values again, change detection sees nothing to save, and
/// the transaction commits without them. Enrolment lost <c>EnrolledAtUtc</c> that way, and a team hand-over
/// its two role changes, each reporting success.
/// </para>
/// <para>
/// So a retry clears the tracker, and reloads each entity the delegate was handed from outside it — the ones
/// it modifies or reads but did not load itself — so they are tracked again with what the database actually
/// holds and the delegate's own assignments register as changes. The first attempt runs exactly as it did
/// before: nothing is cleared or reloaded until something has failed.
/// </para>
/// </remarks>
internal static class FreshAttempts
{
/// <summary>Runs <paramref name="attempt"/> under the context's execution strategy, freshly each time.</summary>
/// <param name="database">The request's context.</param>
/// <param name="carried">Entities loaded before the transaction that the attempt reads or modifies.</param>
/// <param name="attempt">The transaction, which begins and commits its own.</param>
internal static Task<T> ExecuteFreshAsync<T>(
this DodoDbContext database,
IReadOnlyList<object> carried,
Func<Task<T>> attempt)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(carried);
ArgumentNullException.ThrowIfNull(attempt);
var strategy = database.Database.CreateExecutionStrategy();
var attempts = 0;
return strategy.ExecuteAsync(async () =>
{
if (attempts++ > 0)
{
database.ChangeTracker.Clear();
foreach (var entity in carried)
{
await database.Entry(entity).ReloadAsync().ConfigureAwait(false);
}
}
return await attempt().ConfigureAwait(false);
});
}
/// <inheritdoc cref="ExecuteFreshAsync{T}" />
internal static Task ExecuteFreshAsync(
this DodoDbContext database,
IReadOnlyList<object> carried,
Func<Task> attempt) =>
database.ExecuteFreshAsync(
carried,
async () =>
{
await attempt().ConfigureAwait(false);
return true;
});
}
+10 -5
View File
@@ -1,5 +1,6 @@
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Npgsql;
@@ -29,14 +30,18 @@ internal static class Persistence
?? throw new InvalidOperationException(
$"ConnectionStrings:{ConnectionStringName} is not configured.");
services.AddDbContext<DodoDbContext>(options => options
services.AddDbContext<DodoDbContext>((provider, options) => options
.UseNpgsql(connectionString, npgsql => npgsql
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
// Retry on transient faults only. Note this is safe here because writes go
// through explicitly-managed transactions rather than relying on the execution
// strategy to replay ambient ones.
// Retry on transient faults only. A write transaction replayed by the strategy runs on
// the same context, which still holds what the failed attempt tracked — every such
// transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
// the database instead.
.EnableRetryOnFailure(3))
.UseSnakeCaseNamingConvention());
.UseSnakeCaseNamingConvention()
// None in production. The seam a test uses to make a commit fail the way a dropped
// connection would, which is the only way to watch a retry happen.
.AddInterceptors(provider.GetServices<IInterceptor>()));
services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>(
+1
View File
@@ -24,6 +24,7 @@ internal static class RelaySetup
// the network seam holds no state.
services.AddSingleton<RelayTicketCodec>();
services.AddSingleton<RelaySessionRegistry>();
services.AddSingleton<RelayAccessWatch>();
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
// The application name isolates this deployment's payloads from any other ASP.NET application
@@ -1,10 +1,15 @@
using global::Android.OS;
using global::Android.Views;
using Avalonia.Threading;
using DodoSSH.Client.Shell.Keyboard;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
/// back.
/// Takes the keyboard away from the terminal's <c>WebView</c>, reports the taps that should bring it back,
/// and turns a long press and drag into arrow keys.
/// </summary>
/// <remarks>
/// <para>
@@ -36,6 +41,23 @@ namespace DodoSSH.Client.Android.Platform;
/// keyboard over the very output being scrolled to.
/// </para>
/// <para>
/// <b>A long press and a drag is the cursor gesture, and scrolling is still everything else.</b> There is no
/// mode to switch: a finger that moves straight away is a scroll, exactly as before, and the page gets every
/// event of it. A finger held still past <see cref="CursorGestureDelay"/> becomes a joystick for the arrow
/// keys — see <see cref="ArrowGesture"/> for the speeds — and from that moment the rest of the touch is this
/// class's: the <c>WebView</c> is sent a cancel, so it lets go of whatever it thought was starting, and is
/// not shown another event until the finger lifts. A haptic tick says the gesture has begun, because nothing
/// can be drawn over the terminal to say it instead.
/// </para>
/// <para>
/// The delay is a little short of the platform's long-press timeout, and that is the point of it. Chromium
/// starts its own long press — selection, and a <c>contextmenu</c> event the page answers with a paste — at
/// the platform timeout, so taking the touch away first is what keeps a held finger from pasting the
/// clipboard into a shell. Both timers run on the main looper and this one is posted first and shorter, so
/// it wins without a race. With the gesture switched off nothing is taken, and a long press does what it
/// did before.
/// </para>
/// <para>
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
@@ -52,17 +74,26 @@ internal static class RendererFocus
/// </remarks>
private static WeakReference<global::Android.Webkit.WebView>? configured;
/// <summary>How long a finger has to stay still for the cursor gesture, as a share of the long-press timeout.</summary>
private const double CursorGestureDelay = 0.8;
/// <summary>How often a held gesture is asked whether another arrow has come due — about one frame.</summary>
private const long TickMs = 16;
/// <summary>
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
/// taps it.
/// Makes the renderer unable to hold the keyboard, calls <paramref name="onTapped"/> when somebody taps
/// it, and <paramref name="onArrows"/> while somebody long-presses and drags it.
/// </summary>
/// <param name="onTapped">A tap: bring the keyboard back.</param>
/// <param name="gesturesEnabled">Asked at each press, so the setting applies from the next touch.</param>
/// <param name="onArrows">How many arrows to send, and which.</param>
/// <remarks>
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
/// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is
/// free to tear its renderer down and build a new one, which is a case this head already handles
/// elsewhere (see <c>docs/android-port.md</c>).
/// </remarks>
public static void Suppress(Action onTapped)
public static void Suppress(Action onTapped, Func<bool> gesturesEnabled, Action<ArrowDirection, int> onArrows)
{
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
{
@@ -83,57 +114,246 @@ internal static class RendererFocus
webView.FocusableInTouchMode = false;
webView.ClearFocus();
WatchForTaps(webView, onTapped);
WatchTouches(webView, onTapped, gesturesEnabled, onArrows);
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
}
/// <summary>
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
/// Calls <paramref name="onTapped"/> for a tap, runs the cursor gesture for a long press, and stays out of
/// the way of everything else.
/// </summary>
/// <remarks>
/// A tap is a press and a release that did not travel further than the platform's own
/// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious:
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to. The
/// same slop is what cancels a pending long press, so a scroll can never turn into the gesture halfway.
/// </remarks>
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
private static void WatchTouches(
global::Android.Webkit.WebView webView,
Action onTapped,
Func<bool> gesturesEnabled,
Action<ArrowDirection, int> onArrows)
{
// The fallback is the platform's own historical default, for a context that has somehow gone.
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
var downX = 0f;
var downY = 0f;
var moved = false;
var touch = new TouchState(webView, gesturesEnabled, onArrows);
webView.Touch += (_, e) =>
{
// Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
e.Handled = false;
if (e.Event is not { } motion)
{
e.Handled = false;
return;
}
switch (motion.Action)
// Observed and passed on, except while the cursor gesture holds the finger: then the page must not
// see it scroll.
e.Handled = touch.Handle(motion, onTapped);
};
}
/// <summary>One finger's worth of touch on the terminal, from press to release.</summary>
/// <remarks>
/// <para>
/// A class rather than locals captured by the handler, because two timers — the long press and the
/// repeat tick — read and write the same state from outside the touch callback.
/// </para>
/// <para>
/// Avalonia's <see cref="DispatcherTimer"/> rather than <c>View.PostDelayed</c>. On this head the
/// dispatcher is the main looper, so the timers land on the same thread and in the same queue as the
/// touches and as Chromium's own long press; and a dispatcher timer is a plain managed object, where a
/// posted <c>Runnable</c> is a Java peer that would want disposing for the life of a view that has none.
/// </para>
/// </remarks>
private sealed class TouchState
{
private readonly global::Android.Webkit.WebView webView;
private readonly Func<bool> gesturesEnabled;
private readonly Action<ArrowDirection, int> onArrows;
private readonly ArrowGesture gesture = new();
private readonly int slop;
private readonly float density;
private readonly DispatcherTimer longPress;
private readonly DispatcherTimer tick;
private float downX;
private float downY;
private float lastX;
private float lastY;
private long downTime;
private bool moved;
private bool steering;
private int lastSpeed;
internal TouchState(
global::Android.Webkit.WebView webView,
Func<bool> gesturesEnabled,
Action<ArrowDirection, int> onArrows)
{
this.webView = webView;
this.gesturesEnabled = gesturesEnabled;
this.onArrows = onArrows;
// The fallbacks are the platform's own historical defaults, for a context that has somehow gone.
slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
density = webView.Resources?.DisplayMetrics?.Density is > 0 and var d ? d : 1;
longPress = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(ViewConfiguration.LongPressTimeout * CursorGestureDelay),
};
longPress.Tick += (_, _) => BeginSteering();
tick = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(TickMs) };
tick.Tick += (_, _) => Tick();
}
/// <returns>Whether the event is consumed rather than passed on to the page.</returns>
internal bool Handle(MotionEvent motion, Action onTapped)
{
switch (motion.ActionMasked)
{
case MotionEventActions.Down:
downX = motion.GetX();
downY = motion.GetY();
moved = false;
Press(motion);
return false;
break;
case MotionEventActions.PointerDown:
// A second finger is a pinch or a two-finger scroll, never the cursor gesture.
longPress.Stop();
moved = true;
return steering;
case MotionEventActions.Move when steering:
Steer(motion.GetX(), motion.GetY());
return true;
case MotionEventActions.Move:
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
Travel(motion.GetX(), motion.GetY());
return false;
break;
case MotionEventActions.Up or MotionEventActions.Cancel:
return Release(motion.ActionMasked is MotionEventActions.Up, onTapped);
case MotionEventActions.Up when !moved:
onTapped();
break;
default:
return steering;
}
};
}
private void Press(MotionEvent motion)
{
downX = lastX = motion.GetX();
downY = lastY = motion.GetY();
downTime = motion.DownTime;
moved = false;
steering = false;
longPress.Stop();
if (gesturesEnabled())
{
longPress.Start();
}
}
/// <summary>A finger moving before the long press: past the slop it is a scroll, and stays one.</summary>
private void Travel(float x, float y)
{
lastX = x;
lastY = y;
if (!moved && (Math.Abs(x - downX) > slop || Math.Abs(y - downY) > slop))
{
moved = true;
longPress.Stop();
}
}
/// <returns>Whether the release is consumed — it is, when it ends the cursor gesture.</returns>
private bool Release(bool lifted, Action onTapped)
{
longPress.Stop();
if (steering)
{
EndSteering();
return true;
}
if (lifted && !moved)
{
onTapped();
}
return false;
}
/// <summary>The finger has been held still long enough: take the touch and start steering.</summary>
private void BeginSteering()
{
longPress.Stop();
steering = true;
lastSpeed = 0;
gesture.Reset();
// The page has seen a press and nothing since; a cancel tells it that press is over, so neither
// its scroller nor its own long press goes on to do anything with it. Parents are asked not to
// intercept either — the gesture now owns the finger until it lifts.
var now = SystemClock.UptimeMillis();
using (var cancel = MotionEvent.Obtain(downTime, now, MotionEventActions.Cancel, lastX, lastY, MetaKeyStates.None))
{
if (cancel is not null)
{
webView.OnTouchEvent(cancel);
cancel.Recycle();
}
}
webView.Parent?.RequestDisallowInterceptTouchEvent(true);
webView.PerformHapticFeedback(FeedbackConstants.LongPress);
tick.Start();
}
private void Steer(float x, float y)
{
var count = gesture.Move((x - downX) / density, (y - downY) / density, SystemClock.UptimeMillis());
Emit(count);
}
/// <summary>The repeat clock: whatever has come due since the last frame.</summary>
private void Tick()
{
if (steering)
{
Emit(gesture.Advance(SystemClock.UptimeMillis()));
}
}
private void Emit(int count)
{
if (count > 0)
{
onArrows(gesture.Direction, count);
}
// A tick under the thumb each time the speed shifts, which is the only way to feel the three
// levels without looking: the terminal under the finger cannot draw anything to show them.
if (gesture.Speed > lastSpeed && lastSpeed > 0)
{
webView.PerformHapticFeedback(FeedbackConstants.ClockTick);
}
lastSpeed = gesture.Speed;
}
private void EndSteering()
{
steering = false;
tick.Stop();
gesture.Reset();
}
}
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
@@ -647,15 +647,34 @@ internal sealed partial class PhoneShell : UserControl
}
/// <summary>
/// Lowers the connect menu or the forwards sheet, whichever is up over the terminal.
/// Lowers whatever is up over the terminal: the customise sheet, the quick-access keyboard, the connect
/// menu or the forwards sheet.
/// </summary>
/// <returns>Whether one was closed, and so whether back has been spent.</returns>
/// <remarks>
/// Both are raised from the terminal's bar and the scrim under each covers that bar, so at most one is
/// ever open; the order below decides nothing.
/// The three sheets are raised from the terminal's bar or its keys and the scrim under each covers both,
/// so at most one is ever open. The quick-access keyboard comes second because it is the one that can be
/// up with nothing over it — opening the customise sheet lowers it, so the two never compete either.
/// </remarks>
private static bool TryCloseATerminalSheet(MainWindowViewModel current)
{
// The customise sheet, raised from the quick-access keyboard. DONE is the only other way off it, and
// every change on it has already been saved, so back loses nothing.
if (current.Keyboard.IsCustomizing)
{
current.Keyboard.CloseCustomizeCommand.Execute(null);
return true;
}
// The quick-access keyboard stands where the software keyboard stands, and back puts the software
// keyboard away without leaving the terminal — so it does the same for this. Only on the terminal
// surface: the flag outlives a trip to another screen, where there is no panel to lower.
if (current.Keyboard.IsQuickAccessOpen && current.IsTerminalShowing)
{
current.Keyboard.CloseQuickAccess();
return true;
}
if (current.IsConnectSheetOpen)
{
current.CloseConnectSheetCommand.Execute(null);
@@ -0,0 +1,325 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:kb="using:DodoSSH.Client.Shell.Keyboard"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.QuickAccessKeyboard"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource DeepChrome}">
<!--
The quick-access keyboard: every key, snippet and command from history, and the terminal's appearance,
one tap from the prompt.
One control in two places. On a phone it takes the software keyboard's place under the terminal, at the
keyboard's own height so the bar above it does not move; on a wide screen it is a panel beside the
terminal that stays up while the software keyboard comes and goes. Same tabs, same rows, same view model
either way — see TerminalScreen for which one is drawn when.
It reopens to the tab last used, which is a setting rather than a field on this control: the tab is the
one somebody reaches for on this device, and the next launch should know it too. See
TerminalKeyboardViewModel.SelectedTab.
-->
<UserControl.Styles>
<!--
◆ NOTHING HERE TAKES FOCUS, FOR THE REASON THE BAR'S KEYS DO NOT.
This panel is an extension of the keyboard, not a place for the keyboard to go. On a wide screen the
software keyboard can be up at the same time, and an ordinary button would take Avalonia's focus off
TerminalInputSurface on tap — tearing down the input connection and dropping the keyboard every time a
key in this panel was used. On a phone the keyboard is down while this is up, but the focus it would take
is the focus that brings the keyboard straight back when the terminal is tapped. One rule for the whole
control, so a button added to it later cannot forget.
-->
<Style Selector="Button">
<Setter Property="Focusable" Value="False" />
</Style>
<!-- The tab strip's buttons: an icon square, lit when it is the tab showing. -->
<Style Selector="Button.qtab">
<Setter Property="Width" Value="48" />
<Setter Property="Height" Value="40" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
<Setter Property="FontSize" Value="20" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.qtab:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<Style Selector="Button.qtab.selected">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<Style Selector="Button.qtab.selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentSoft}" />
</Style>
<!-- A group's caption over its keys. -->
<Style Selector="TextBlock.groupcaption">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="9.5" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="4,10,0,4" />
</Style>
<!-- Keys in a panel are a little shorter than the bar's: there are many more of them, all reachable. -->
<Style Selector="Button.hotkey.panelkey">
<Setter Property="Height" Value="38" />
<Setter Property="MinHeight" Value="38" />
<Setter Property="Margin" Value="3" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*">
<!-- ============ the tabs ============ -->
<!--
Icons rather than words, as the keys tab's own grid is: four words at 360dp leave no room for the
keyboard button at the end, and each glyph is the one every phone uses for the thing — a grid of keys,
braces for snippets, a clock for history, a palette for appearance. Each carries its name for
accessibility, which is where a word belongs when there is no room to print one.
-->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="8,6,8,2">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4">
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsKeysTab}" Content="&#xE5C3;"
AutomationProperties.Name="Keys"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.Keys}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsSnippetsTab}" Content="&#xE86F;"
AutomationProperties.Name="Snippets"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.Snippets}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsHistoryTab}" Content="&#xE889;"
AutomationProperties.Name="History"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.History}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsAppearanceTab}" Content="&#xE40A;"
AutomationProperties.Name="Appearance"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.Appearance}" />
</StackPanel>
<!--
Back to the phone's own keyboard. The one control here that is not a tab, so it sits apart behind a
hairline: it does not change what this panel shows, it puts the panel away.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
<Border Width="1" Height="22" Background="{StaticResource Border}" VerticalAlignment="Center" />
<Button Classes="qtab" Content="&#xE312;" AutomationProperties.Name="Show the keyboard"
Click="OnKeyboardRequested" />
</StackPanel>
</Grid>
<Panel Grid.Row="1">
<!-- ============ KEYS ============ -->
<ScrollViewer IsVisible="{Binding Keyboard.IsKeysTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,4,8,12">
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center"
Command="{Binding Keyboard.OpenCustomizeCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE429;"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="10" Text="CUSTOMIZE" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!--
The bar's own keys, first, when the bar is not drawn — which is a wide screen with this panel
open beside the terminal. A key somebody put on their bar is one they want within reach, and
hiding the bar must not hide it.
-->
<StackPanel IsVisible="{Binding $parent[views:QuickAccessKeyboard].ShowsBarGroup}">
<TextBlock Classes="groupcaption" Text="YOUR BAR" />
<ItemsControl ItemsSource="{Binding Keyboard.BarRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyRowViewModel">
<ItemsControl ItemsSource="{Binding Keys}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyButtonViewModel">
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
Command="{Binding PressCommand}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<ItemsControl ItemsSource="{Binding Keyboard.Groups}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyGroupViewModel">
<StackPanel>
<TextBlock Classes="groupcaption" Text="{Binding Title}" />
<!--
Four across, which at 360dp is an 84dp key — wide enough for "shift+tab" and "ctrl+B %"
in the mono face without shrinking it, and still four keys a thumb can tell apart.
-->
<ItemsControl ItemsSource="{Binding Keys}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyButtonViewModel">
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
Command="{Binding PressCommand}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<!-- ============ SNIPPETS ============ -->
<!--
The keychain's snippets, through the shell's own InsertSnippet — the command the desktop sidebar's
rows run, which carries the snippets screen's whole safety story: typed rather than pasted for one
line, and no Enter unless the snippet was saved as one that runs. A second insert path here would be
a second place that story could go stale. The ▶ says which ones run.
-->
<ScrollViewer IsVisible="{Binding Keyboard.IsSnippetsTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,4,8,12" Spacing="2">
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible, FallbackValue={x:Null}}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Button Classes="row" MinHeight="48" Padding="12,6"
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Label}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Preview}"
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="label" FontSize="9" Text="▶ RUNS" Margin="10,0,0,0"
Foreground="{StaticResource WarnText}" VerticalAlignment="Center"
IsVisible="{Binding RunsOnInsert}" />
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
Text="Tap a snippet to type it at the prompt. Snippets live in the keychain, where they are added and edited." />
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
Command="{Binding ShowScreenCommand}" CommandParameter="{x:Static vm:ShellScreen.Snippets}">
<TextBlock Classes="label" FontSize="9" Text="MANAGE SNIPPETS" />
</Button>
</StackPanel>
</ScrollViewer>
<!-- ============ HISTORY ============ -->
<!--
What was typed on this phone and echoed back by the remote — never a line typed at a password prompt,
which echoes nothing. Kept in memory only and emptied by LOCK; the sentence under the list says so,
because a list of commands is exactly the kind of thing somebody should know the lifetime of. See
TypedCommandHistory for the whole of what is and is not kept.
-->
<ScrollViewer IsVisible="{Binding Keyboard.IsHistoryTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,4,8,12" Spacing="2">
<ItemsControl ItemsSource="{Binding Keyboard.History}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="kb:HistoryEntry">
<Button Classes="row" MinHeight="44" Padding="12,6"
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).Keyboard.InsertHistoryCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5" Text="{Binding Command}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="detail" FontSize="10" Text="{Binding Host}"
Margin="10,0,0,0" VerticalAlignment="Center" />
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
IsVisible="{Binding !Keyboard.HasHistory}"
Text="Commands you type here appear in this list. Only what the remote echoes back is kept, so nothing typed at a password prompt ever is." />
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
Text="Tap a command to type it at the prompt without running it. The list is kept in memory only, and locking the keychain clears it." />
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
IsVisible="{Binding Keyboard.HasHistory}"
Command="{Binding Keyboard.ClearHistoryCommand}">
<TextBlock Classes="label" FontSize="9" Text="CLEAR HISTORY" />
</Button>
</StackPanel>
</ScrollViewer>
<!-- ============ APPEARANCE ============ -->
<!--
The text size, which used to ride at the end of the arrow row as A− and A+. A phone cannot press
Ctrl+plus, so these are still buttons; they moved here because the bar is now somebody's own and two
fixed cells at its end were cells they could not have. Disabled at the ends rather than clamping
silently, for the reason they always were: a button that accepts taps and does nothing reads as the
terminal having stopped responding.
And the cursor gesture's switch, for somebody whose long press means something else to them.
-->
<ScrollViewer IsVisible="{Binding Keyboard.IsAppearanceTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="12,6,12,12" Spacing="8">
<TextBlock Classes="groupcaption" Text="TEXT SIZE" Margin="0,4,0,0" />
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Button Grid.Column="0" Classes="hotkey" Width="56" Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A−" />
</Button>
<TextBlock Grid.Column="1" Classes="mono" FontSize="14" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="{Binding TerminalFontSize, StringFormat='{}{0} px'}" />
<Button Grid.Column="2" Classes="hotkey" Width="56" Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
<Button Grid.Column="3" Classes="row" MinHeight="40" Padding="12,0" Margin="8,0,0,0"
Command="{Binding ResetTerminalFontCommand}">
<TextBlock Classes="label" FontSize="9" Text="RESET" />
</Button>
</Grid>
<TextBlock Classes="groupcaption" Text="CURSOR GESTURE" Margin="0,10,0,0" />
<Button Classes="row" MinHeight="52" Padding="12,8" Command="{Binding Keyboard.ToggleCursorGesturesCommand}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="Long-press and drag" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Text="Moves the cursor or walks through history. Drag further to go faster." />
</StackPanel>
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="ON" Margin="10,0,0,0"
Foreground="{StaticResource Live}" VerticalAlignment="Center"
IsVisible="{Binding Keyboard.CursorGestures}" />
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="OFF" Margin="10,0,0,0"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
IsVisible="{Binding !Keyboard.CursorGestures}" />
</Grid>
</Button>
</StackPanel>
</ScrollViewer>
</Panel>
</Grid>
</UserControl>
@@ -0,0 +1,40 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>The quick-access keyboard: keys, snippets, history and appearance, in four tabs.</summary>
internal sealed partial class QuickAccessKeyboard : UserControl
{
/// <summary>Whether the KEYS tab repeats the bar's own keys at the top.</summary>
/// <remarks>
/// On for the wide screen's side panel, where the bar is not drawn; off under a phone's terminal, where
/// the bar is directly above this and a second copy would be two rows of the same keys.
/// </remarks>
public static readonly StyledProperty<bool> ShowsBarGroupProperty =
AvaloniaProperty.Register<QuickAccessKeyboard, bool>(nameof(ShowsBarGroup));
public QuickAccessKeyboard() => AvaloniaXamlLoader.Load(this);
/// <summary>
/// The keyboard button in the tab strip was pressed: put this panel away and bring the phone's back.
/// </summary>
/// <remarks>
/// An event rather than a command, because both halves are platform calls the shared view model has no
/// business making — showing the software keyboard means focusing the terminal's input surface, which
/// only <see cref="TerminalScreen"/> holds.
/// </remarks>
public event EventHandler? KeyboardRequested;
/// <inheritdoc cref="ShowsBarGroupProperty" />
public bool ShowsBarGroup
{
get => GetValue(ShowsBarGroupProperty);
set => SetValue(ShowsBarGroupProperty, value);
}
private void OnKeyboardRequested(object? sender, RoutedEventArgs e) =>
KeyboardRequested?.Invoke(this, EventArgs.Empty);
}
@@ -167,7 +167,7 @@
job — see the remark on the header in PhoneShell.axaml. -->
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<!--
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
@@ -304,11 +304,34 @@
<Button Grid.Column="3" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
Click="OnSheetRequested"
Command="{Binding OpenConnectSheetCommand}" />
<!--
The wide screen's side panel, shown and hidden — the quick-access keyboard beside the terminal
rather than under it. Here rather than at the start of the key bar, which is where a phone keeps
its toggle: on a tablet the bar is not drawn while the panel is open, and the control that brings
the panel back cannot live inside the thing the panel replaced. Lit while open, like a latched key.
-->
<Button Grid.Column="4" Classes="icon" Height="34" Margin="0,0,4,0"
Classes.accent="{Binding Keyboard.IsSidePanelOpen}"
AutomationProperties.Name="Keys, snippets and history"
IsVisible="{Binding $parent[views:TerminalScreen].IsWide}"
Command="{Binding Keyboard.ToggleSidePanelCommand}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="&#xF114;"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</Grid>
</Border>
<!-- ============ the renderer ============ -->
<Panel Grid.Row="1">
<!-- ============ the renderer, and the wide screen's side panel ============ -->
<!--
Two columns, and the second is collapsed except on a wide screen with the panel open. Beside the
renderer rather than over it, for the reason nothing on this head is drawn over it: the WebView is a
native child composited above everything Avalonia paints, so a panel laid on top would be invisible.
Taking its width off the terminal refits the grid and tells the remote, exactly as the software
keyboard taking its height does.
-->
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
<Panel Grid.Column="0">
<!--
============ nothing open ============
@@ -551,60 +574,21 @@
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
<!-- ============ accessory keys ============ -->
<!--
The row every Android SSH client ships, and the reason is not preference: a software keyboard has no
Ctrl, no Esc, no Tab and no arrows, and without them a phone cannot interrupt a process, complete a
path, or reach the previous command. Ctrl and Alt latch — pressed once they apply to the next key and
then release, because holding a modifier while typing is not possible one-thumbed.
The quick-access keyboard as a side panel: additional keys, snippets, history and appearance in view
while the terminal is worked in, which is what a tablet's width is for. 320 wide, which at the 600dp
where this head turns wide leaves the terminal 280 — a narrow terminal, and a panel somebody can close
from the bar above; at a tablet's 900 it is a third.
ShowsBarGroup, because the bar under the terminal stands down while this is open and the keys someone
put on it have to go somewhere they can still be reached.
-->
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
<!--
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
<Border Grid.Column="1" Width="320" BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
IsVisible="{Binding $parent[views:TerminalScreen].ShowsSidePanel}">
<views:QuickAccessKeyboard x:Name="SidePanel" ShowsBarGroup="True" />
</Border>
</Grid>
What this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
and it was wrong in two ways that compounded each other.
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
to be the first thing anybody noticed about this row.
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
which is a trade worth making right up until the control stops working, and 30 is past that.
── what the other clients do, because all of them agree ─────────────────────────────────────────────
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
second row — Termux ships two by default. That is not convention for its own sake: a key whose
position is fixed can be hit without looking, which is the entire point of a modifier row you use
while reading the output above it. A key that might have scrolled has to be found first.
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
ones that benefit, because the arrows were the keys living past the right edge.
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
Ctrl is not the better surface.
── the split ────────────────────────────────────────────────────────────────────────────────────────
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of
its own — and a terminal whose last line of output is flush against a row of grey keys reads as one
surface that has gone wrong rather than as two that are different things.
-->
<!-- ============ what a terminal says once its shell has stopped ============ -->
<!--
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
@@ -654,71 +638,90 @@
</StackPanel>
</Border>
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
<StackPanel Spacing="4">
<!-- ============ the keys ============ -->
<!--
The keys a software keyboard does not have, and the way to all the others.
── the bar ──────────────────────────────────────────────────────────────────────────────────────────
The row every Android SSH client ships, and the reason is not preference: a software keyboard has no
Ctrl, no Esc, no Tab and no arrows, and without them a phone cannot interrupt a process, complete a
path, or reach the previous command. Ctrl and Alt latch — pressed once they apply to the next key, from
the bar or from the phone's own keyboard, and then release — because holding a modifier while typing is
not possible one-thumbed.
It is somebody's own now: which keys, in what order, set on the customise screen and kept on this
device. What did not change is the shape the old fixed keypad argued its way to, and the argument still
holds. Nothing here scrolls — a horizontally scrollable strip reads a thumb coming down at an angle as
the start of a pan, and the key never sees the press. Every key gets an equal share of its row's width,
so "ctrl" and "|" are the same target. At most eight to a row and two rows, which keeps a key at about
41dp on a 360dp phone; anything past sixteen belongs on the quick-access keyboard. The first eight are
the top row, so a person arranging their bar decides the rows by the order alone.
As shipped: modifiers and punctuation on top, cursor keys underneath, nearest the thumb, because they
are the keys pressed repeatedly and in sequence — history, then along the line.
── the grid button ──────────────────────────────────────────────────────────────────────────────────
At the bar's left-hand end, outside the rows, and the one key nobody can remove: it is the way to the
quick-access keyboard and from there to the customise screen, so a bar arranged to nothing is still a
bar with a way back. Lit while the panel is up, as a latched modifier is, because it is a state.
── the panel ────────────────────────────────────────────────────────────────────────────────────────
The quick-access keyboard, in the software keyboard's place: the code-behind puts the phone's keyboard
away as this opens and gives this the height that keyboard last had, so the bar does not move between
the two. Tapping the terminal brings the phone's keyboard back and puts this away again.
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its
own — and a terminal whose last line is flush against a row of grey keys reads as one surface gone wrong.
-->
<StackPanel Grid.Row="3" IsVisible="{Binding $parent[views:TerminalScreen].ShowsKeys}">
<Grid ColumnDefinitions="Auto,*" Margin="8,5,8,4">
<!--
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
between them instead of taking what their labels happen to need — "ctrl" is four characters and
"|" is one, and on a row of keys those two want the same target, not proportional ones.
Focusable="False", for the reason every key carries it — see the remark on Button.hotkey's use in
QuickAccessKeyboard. A button here that took focus would take it off the terminal's input surface,
and the keyboard would go with it.
-->
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
<Button Grid.Column="0" Classes="hotkey" Width="44" Focusable="False" VerticalAlignment="Bottom"
Margin="0,0,4,0"
Classes.latched="{Binding Keyboard.IsQuickAccessOpen}"
AutomationProperties.Name="Keys, snippets and history"
IsVisible="{Binding !$parent[views:TerminalScreen].IsWide}"
Click="OnQuickAccessToggled">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="&#xE5C3;"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<!--
The cursor keys, and the two text-size buttons riding at the end.
<ItemsControl Grid.Column="1" ItemsSource="{Binding Keyboard.BarRows}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Spacing="4" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyRowViewModel">
<ItemsControl ItemsSource="{Binding Keys}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Rows="1" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyButtonViewModel">
<Button Classes="hotkey" Focusable="False" Classes.latched="{Binding IsLatched}"
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
Command="{Binding PressCommand}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
them every time the row's contents changed.
-->
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
<!--
The text-size buttons.
They used to have a row of their own above the terminal, on the argument that the accessory row
scrolls and these two must never be off-screen — a terminal too small to read is exactly the
state in which hunting for the control that fixes it is worst. Nothing on this keypad scrolls
any more, so that argument is now satisfied by construction rather than by placement.
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
has no keyboard to press it with, which is why the two heads differ here and nowhere else in
this screen.
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
nothing reads as the terminal having stopped responding, which is the one thing this screen must
never look like.
◆ Focusable="False", for the reason the accessory keys carry in code — see
TerminalScreen.axaml.cs. Every button on this keypad sits over a terminal somebody is typing
into, and an ordinary one takes Avalonia's focus off the input surface on tap. Making the text
smaller must not cost the keyboard.
-->
<!-- No Spacing: Button.hotkey carries a 2-pixel side margin, so the gaps here match the
UniformGrid cells' without being counted twice. -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="8,0,0,0">
<Border Width="1" Height="20" Background="{StaticResource Border}" Margin="0,0,4,0"
VerticalAlignment="Center" />
<Button Classes="hotkey" Width="44" Focusable="False"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A−" />
</Button>
<Button Classes="hotkey" Width="44" Focusable="False"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
Height="{Binding $parent[views:TerminalScreen].QuickAccessHeight}"
IsVisible="{Binding $parent[views:TerminalScreen].ShowsQuickAccessPanel}">
<views:QuickAccessKeyboard x:Name="BottomPanel" />
</Border>
</StackPanel>
</Grid>
@@ -866,6 +869,185 @@
</Border>
</Panel>
<!-- ============ the customise sheet ============ -->
<!--
Reached from CUSTOMIZE at the top of the quick-access keyboard's KEYS tab: which keys are on the bar
and in what order, which groups the KEYS tab shows and in what order, and keys of one's own.
Taller than the other two sheets, nearly the whole display, because it is a page of settings rather
than a menu — and it scrolls for the same reason. It takes the screen the way they do too: opening it
collapses the renderer (see MainWindowViewModel.IsTerminalShowing), so nothing is drawn over the
WebView and the boxes on it can have the keyboard.
Every change applies the moment it is made, to the bar and to the settings file, which is why there is
DONE and no SAVE. See TerminalKeyboardViewModel.
↑ and ↓ rather than drag handles. A drag inside a scrolling sheet is the same gesture as a scroll and
has to be told apart by a long press nobody can see; two buttons are slower and never wrong.
-->
<Panel IsVisible="{Binding Keyboard.IsCustomizing}">
<Button Classes="scrim" Command="{Binding Keyboard.CloseCustomizeCommand}" />
<Border Margin="0,40,0,0" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
CornerRadius="22,22,0,0" Padding="20,18,20,0">
<Grid RowDefinitions="Auto,Auto,*">
<Border Grid.Row="0" Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
HorizontalAlignment="Center" Margin="0,0,0,12" />
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="0,0,0,8">
<TextBlock Grid.Column="0" Classes="title" Text="CUSTOMIZE KEYS" FontSize="13"
VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="row" MinHeight="40" Padding="14,0"
Command="{Binding Keyboard.CloseCustomizeCommand}">
<TextBlock Classes="label" FontSize="9" Text="DONE" />
</Button>
</Grid>
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="6" Margin="0,0,0,24">
<!-- ── the bar ── -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
<TextBlock Grid.Column="0" Classes="label" Text="THE BAR" />
<TextBlock Grid.Column="1" Classes="detail" FontSize="10" Text="{Binding Keyboard.BarCountText}" />
</Grid>
<TextBlock Classes="body" TextWrapping="Wrap"
Text="The first eight keys are the top row and the rest the second. Tap a key in a group below to put it on the bar or take it off." />
<ItemsControl ItemsSource="{Binding Keyboard.BarEditor}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:BarEditorRowViewModel">
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,4">
<Grid ColumnDefinitions="64,*,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="SemiBold"
Text="{Binding Label}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<StackPanel Grid.Column="1" Spacing="1" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Hint}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="9" Foreground="{StaticResource TextFaint}"
Text="{Binding Row}" />
</StackPanel>
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
AutomationProperties.Name="Move earlier" Command="{Binding MoveUpCommand}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="&#xE5D8;" />
</Button>
<Button Grid.Column="3" Classes="icon" Height="40" Width="40"
AutomationProperties.Name="Move later" Command="{Binding MoveDownCommand}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="&#xE5DB;" />
</Button>
<Button Grid.Column="4" Classes="icon" Height="40" Width="40"
AutomationProperties.Name="Take off the bar" Command="{Binding RemoveCommand}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="&#xE5CD;"
Foreground="{StaticResource TextFaint}" />
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Classes="body" TextWrapping="Wrap" IsVisible="{Binding !Keyboard.HasBar}"
Text="The bar is empty, so only the grid button is drawn above the keyboard." />
<!-- ── the groups ── -->
<TextBlock Classes="label" Text="GROUPS" Margin="0,16,0,0" />
<TextBlock Classes="body" TextWrapping="Wrap"
Text="The quick-access keyboard shows the ticked groups, in this order. A lit key is on the bar." />
<ItemsControl ItemsSource="{Binding Keyboard.GroupEditor}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GroupEditorRowViewModel">
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,6,0,10">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto,Auto">
<CheckBox Grid.Column="0" IsChecked="{Binding IsShown}" MinHeight="40">
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Title}" />
</CheckBox>
<Button Grid.Column="1" Classes="icon" Height="40" Width="40"
AutomationProperties.Name="Move up" Command="{Binding MoveUpCommand}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="&#xE5D8;" />
</Button>
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
AutomationProperties.Name="Move down" Command="{Binding MoveDownCommand}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="&#xE5DB;" />
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding Keys}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyChoiceViewModel">
<Button Classes="chiptoggle" Classes.worn="{Binding IsOnBar}" Margin="0,0,6,6"
Command="{Binding ToggleCommand}">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- ── keys of one's own ── -->
<TextBlock Classes="label" Text="ADD A KEY" Margin="0,16,0,0" />
<ItemsControl ItemsSource="{Binding Keyboard.CustomKeyEditor}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CustomKeyRowViewModel">
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,4">
<Grid ColumnDefinitions="64,*,Auto">
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="SemiBold"
Text="{Binding Label}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Classes="detail" FontSize="10.5" Text="{Binding Sequence}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
AutomationProperties.Name="Delete this key" Command="{Binding DeleteCommand}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="&#xE872;"
Foreground="{StaticResource DangerText}" />
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
ShowSuggestions off on both boxes, for the reason the terminal's own input surface turns it off:
an autocorrecting keyboard capitalises the e in \e and puts a full stop after ^C, and the key
then sends something nobody typed.
-->
<TextBox Classes="field" Text="{Binding Keyboard.NewKeyLabel}" MaxLength="10"
TextInputOptions.ShowSuggestions="False"
PlaceholderText="Label, as it appears on the key" />
<TextBox Classes="field" Text="{Binding Keyboard.NewKeySequence}"
TextInputOptions.ShowSuggestions="False"
PlaceholderText="What it sends, e.g. \e:wq\r or ^C" />
<TextBlock Classes="body" TextWrapping="Wrap"
Text="\e is Escape, \r is Enter, \t is Tab, and ^ before a letter is Ctrl; \^ is a plain caret. Keys are kept on this phone in plain text, so this is no place for a password." />
<Button Classes="primary" Content="ADD KEY" Command="{Binding Keyboard.AddCustomKeyCommand}" />
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
Text="{Binding Keyboard.CustomizeStatus}"
IsVisible="{Binding Keyboard.CustomizeStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<Button Classes="secondary" Content="RESET TO DEFAULTS" Margin="0,16,0,0"
Command="{Binding Keyboard.ResetLayoutCommand}" />
<TextBlock Classes="detail" TextWrapping="Wrap"
Text="Puts the bar and the groups back as they shipped. Keys you added are kept." />
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</Panel>
</Panel>
</UserControl>
@@ -1,17 +1,16 @@
using System.ComponentModel;
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Platform;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Shell.Keyboard;
using DodoSSH.Client.Shell.ViewModels;
// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
@@ -22,85 +21,45 @@ using Button = Avalonia.Controls.Button;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design 03 — the terminal, and the accessory key row under it.</summary>
/// <summary>Design 03 — the terminal, and the keys under or beside it.</summary>
internal sealed partial class TerminalScreen : UserControl
{
/// <summary>
/// The keys a software keyboard does not have.
/// The height the quick-access keyboard takes on a phone until the software keyboard has been measured.
/// </summary>
/// <remarks>
/// <para>
/// Chosen by what a shell session actually needs rather than by what a keyboard has: Esc leaves vi's
/// insert mode, Tab completes a path, Ctrl makes <c>^C</c> reachable, the arrows reach history, and the
/// pipe and hyphen are two characters that are three taps deep on every Android keyboard and appear in
/// almost every command worth typing on a phone.
/// </para>
/// <para>
/// The order matches the design's row. It is scrollable rather than compressed, because shrinking ten
/// keys to fit 360dp produces ten targets too small to hit.
/// </para>
/// About what Gboard and Samsung's keyboard take in portrait. It is only a starting point: the first time
/// the software keyboard is up, its own height replaces this, so the bar above the panel does not move
/// when one is swapped for the other.
/// </remarks>
/// <summary>The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.</summary>
/// <remarks>
/// Seven, which is what the row divides between — see the keypad's remark in TerminalScreen.axaml for
/// why the set is fixed and flexed rather than scrollable. Adding an eighth is a matter of adding it
/// here and changing the <c>Columns</c> on the UniformGrid; the two numbers have to agree, and the row
/// simply gets narrower keys.
/// </remarks>
private static readonly (string Label, byte[] Bytes, bool Latches)[] ModifierKeys =
[
("esc", [0x1B], false),
("tab", [0x09], false),
("ctrl", [], true),
("|", [(byte)'|'], false),
("-", [(byte)'-'], false),
("~", [(byte)'~'], false),
("/", [(byte)'/'], false),
];
private const double DefaultQuickAccessHeight = 280;
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
/// <remarks>
/// <para>
/// The ANSI sequences a PTY expects, written out rather than composed, because the difference between
/// CSI A and SS3 A is the difference between working in bash and not.
/// </para>
/// <para>
/// ◆ <b>Left, down, up, right</b> — the order every other client on the platform puts them in, and not
/// the order they were in here. On one flat row the pairs have to read as pairs: horizontal at the ends
/// and vertical in the middle means the two keys that undo each other are always neighbours, and a
/// thumb learns the row's shape rather than its labels. Up-down-left-right is the order they are named
/// in, which is a different thing from the order they are used in.
/// </para>
/// <para>
/// These four were the keys that used to live past the right-hand edge of the scroller — the most
/// pressed keys on the keypad, and the ones you had to go looking for.
/// </para>
/// </remarks>
private static readonly (string Label, byte[] Bytes, bool Latches)[] CursorKeys =
[
("←", [0x1B, (byte)'[', (byte)'D'], false),
("↓", [0x1B, (byte)'[', (byte)'B'], false),
("↑", [0x1B, (byte)'[', (byte)'A'], false),
("→", [0x1B, (byte)'[', (byte)'C'], false),
];
public static readonly StyledProperty<bool> IsWideProperty =
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(IsWide));
/// <summary>
/// Whether the next ordinary key should be sent as a control character.
/// </summary>
/// <remarks>
/// Latching rather than held. Holding a modifier while typing needs two hands and a keyboard that
/// reports chords, and this row has neither — so Ctrl is pressed, then C, and releases itself. Every
/// Android SSH client does this and users expect it.
/// </remarks>
private bool controlLatched;
public static readonly StyledProperty<bool> ShowsKeysProperty =
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsKeys));
private Button? controlKey;
public static readonly StyledProperty<bool> ShowsQuickAccessPanelProperty =
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsQuickAccessPanel));
public static readonly StyledProperty<bool> ShowsSidePanelProperty =
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsSidePanel));
public static readonly StyledProperty<double> QuickAccessHeightProperty =
AvaloniaProperty.Register<TerminalScreen, double>(nameof(QuickAccessHeight), DefaultQuickAccessHeight);
private readonly TerminalInputSurface input;
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
private MainWindowViewModel? subscribed;
/// <summary>The phone shell this screen sits in, for its width class.</summary>
private PhoneShell? phoneShell;
/// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary>
private IInputPane? softKeyboard;
/// <summary>
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
/// </summary>
@@ -115,10 +74,14 @@ internal sealed partial class TerminalScreen : UserControl
{
AvaloniaXamlLoader.Load(this);
BuildAccessoryRow();
input = this.FindControl<TerminalInputSurface>("Input")!;
input.Input += (_, e) => SendAsync(e.Bytes);
// Everything typed on the phone's own keyboard goes through the same path as the bar's keys, which is
// what lets Ctrl latched on the bar apply to a letter typed on Gboard. See TerminalKeyboardViewModel.
input.Input += (_, e) => Keyboard?.Send(e.Bytes);
this.FindControl<QuickAccessKeyboard>("BottomPanel")!.KeyboardRequested += OnKeyboardRequested;
this.FindControl<QuickAccessKeyboard>("SidePanel")!.KeyboardRequested += OnKeyboardRequested;
var renderer = this.FindControl<NativeWebView>("Renderer")!;
@@ -140,6 +103,7 @@ internal sealed partial class TerminalScreen : UserControl
{
previous.PropertyChanged -= OnShellPropertyChanged;
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
previous.Keyboard.PropertyChanged -= OnKeyboardPropertyChanged;
subscribed = null;
}
@@ -153,13 +117,250 @@ internal sealed partial class TerminalScreen : UserControl
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
// they look. See MainWindowViewModel.TerminalFocusRequested.
shell.TerminalFocusRequested += OnTerminalFocusRequested;
shell.Keyboard.PropertyChanged += OnKeyboardPropertyChanged;
subscribed = shell;
terminalWasShowing = shell.IsTerminalShowing;
}
RefreshKeys();
};
}
/// <summary>Whether the screen is wide enough for the side panel. <see cref="PhoneShell.IsWide"/>, mirrored.</summary>
public bool IsWide
{
get => GetValue(IsWideProperty);
private set => SetValue(IsWideProperty, value);
}
/// <summary>Whether the bar under the terminal is drawn.</summary>
public bool ShowsKeys
{
get => GetValue(ShowsKeysProperty);
private set => SetValue(ShowsKeysProperty, value);
}
/// <summary>Whether the quick-access keyboard is drawn under the bar, in the software keyboard's place.</summary>
public bool ShowsQuickAccessPanel
{
get => GetValue(ShowsQuickAccessPanelProperty);
private set => SetValue(ShowsQuickAccessPanelProperty, value);
}
/// <summary>Whether the quick-access keyboard is drawn beside the terminal.</summary>
public bool ShowsSidePanel
{
get => GetValue(ShowsSidePanelProperty);
private set => SetValue(ShowsSidePanelProperty, value);
}
/// <summary>How tall the quick-access keyboard is on a phone: the software keyboard's last height.</summary>
public double QuickAccessHeight
{
get => GetValue(QuickAccessHeightProperty);
private set => SetValue(QuickAccessHeightProperty, value);
}
private TerminalKeyboardViewModel? Keyboard => (DataContext as MainWindowViewModel)?.Keyboard;
/// <inheritdoc />
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
phoneShell = this.FindAncestorOfType<PhoneShell>();
if (phoneShell is not null)
{
phoneShell.PropertyChanged += OnPhoneShellPropertyChanged;
}
softKeyboard = TopLevel.GetTopLevel(this)?.InputPane;
if (softKeyboard is not null)
{
softKeyboard.StateChanged += OnSoftKeyboardChanged;
}
RefreshKeys();
}
/// <inheritdoc />
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
if (phoneShell is not null)
{
phoneShell.PropertyChanged -= OnPhoneShellPropertyChanged;
phoneShell = null;
}
if (softKeyboard is not null)
{
softKeyboard.StateChanged -= OnSoftKeyboardChanged;
softKeyboard = null;
}
base.OnDetachedFromVisualTree(e);
}
/// <summary>
/// Works out which of the three key surfaces is drawn: the bar, the panel under it, the panel beside.
/// </summary>
/// <remarks>
/// <para>
/// Computed here rather than written as conditions in the markup, for PhoneShell's reason: each is an
/// "and" of facts from three objects — the shell, the keyboard and the width — and a binding has no
/// "and". Recomputed on any notification from any of the three, which is cheaper than being wrong about
/// which of their properties are computed ones.
/// </para>
/// <para>
/// <b>Wide, the side panel replaces the bar while it is open.</b> Its KEYS tab repeats the bar's keys at
/// the top, so nothing somebody put on their bar goes out of reach, and a tablet keeps the height. Closed,
/// the bar comes back exactly as on a phone.
/// </para>
/// <para>
/// <b>Wide, the bottom panel never shows.</b> Rotating a phone into the wide class with the panel up would
/// otherwise leave it raised under a terminal that also has the side panel; it is lowered instead, and the
/// side panel is where the same tabs are.
/// </para>
/// </remarks>
private void RefreshKeys()
{
var wide = phoneShell?.IsWide == true;
var showing = subscribed?.IsTerminalShowing == true;
var keyboard = subscribed?.Keyboard;
var sidePanel = wide && keyboard?.IsSidePanelOpen == true;
if (wide && keyboard is { IsQuickAccessOpen: true })
{
keyboard.CloseQuickAccess();
}
IsWide = wide;
ShowsSidePanel = showing && sidePanel;
ShowsKeys = showing && !sidePanel;
ShowsQuickAccessPanel = showing && !wide && keyboard?.IsQuickAccessOpen == true;
}
private void OnPhoneShellPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == PhoneShell.IsWideProperty)
{
RefreshKeys();
}
}
private void OnKeyboardPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(TerminalKeyboardViewModel.IsQuickAccessOpen)
or nameof(TerminalKeyboardViewModel.IsSidePanelOpen):
RefreshKeys();
break;
// The customise sheet has boxes of its own, and on a wide screen it can be opened while the
// terminal's keyboard is up. Put away here so the sheet is laid out into the whole display
// rather than the strip above a keyboard configured for a shell.
case nameof(TerminalKeyboardViewModel.IsCustomizing)
when sender is TerminalKeyboardViewModel { IsCustomizing: true }:
SoftKeyboard.Hide();
break;
}
}
/// <summary>
/// Notes the software keyboard's height, and lowers the quick-access keyboard when it comes back.
/// </summary>
/// <remarks>
/// <para>
/// The two cannot both be up on a phone — they are the same place on the screen — and the software
/// keyboard arriving is the one signal every way of bringing it back has in common: a tap on the
/// terminal, the panel's own keyboard button, a box elsewhere taking focus.
/// </para>
/// <para>
/// The height is kept only when it is plausibly a keyboard. Before Android 15 the window is resized for
/// the keyboard instead and the inset reported here is zero (see PhoneShell.ApplyKeyboardInset); a panel
/// sized from that would be no panel at all, so the default stands.
/// </para>
/// </remarks>
private void OnSoftKeyboardChanged(object? sender, InputPaneStateEventArgs e)
{
if (e.NewState is not InputPaneState.Open)
{
return;
}
RememberKeyboardHeight(e.EndRect.Height);
Keyboard?.CloseQuickAccess();
}
private void RememberKeyboardHeight(double height)
{
if (double.IsFinite(height) && height >= 160)
{
QuickAccessHeight = Math.Min(height, 480);
}
}
/// <summary>
/// The grid button: swaps the software keyboard for the quick-access keyboard, or back.
/// </summary>
/// <remarks>
/// <para>
/// A click handler rather than the view model's toggle alone, because both halves of the swap are
/// platform calls: the software keyboard is measured and put away on the way in, and brought back by
/// focusing the input surface on the way out. The view model only records which of the two is up.
/// </para>
/// <para>
/// The panel is raised before the keyboard is hidden. The two arrive as separate layout passes either
/// way; this order means the frame between them is the panel over a closing keyboard rather than a
/// terminal briefly the full height of the display.
/// </para>
/// </remarks>
private void OnQuickAccessToggled(object? sender, RoutedEventArgs e)
{
if (Keyboard is not { } keyboard)
{
return;
}
if (keyboard.IsQuickAccessOpen)
{
keyboard.CloseQuickAccess();
input.Acquire();
return;
}
if (softKeyboard is { State: InputPaneState.Open } pane)
{
RememberKeyboardHeight(pane.OccludedRect.Height);
}
keyboard.ToggleQuickAccessCommand.Execute(null);
SoftKeyboard.Hide();
}
/// <summary>The panel's keyboard button: put the panel away and bring the phone's keyboard back.</summary>
private void OnKeyboardRequested(object? sender, EventArgs e)
{
Keyboard?.CloseQuickAccess();
input.Acquire();
}
/// <summary>A tap on the terminal: the phone's keyboard, in place of the panel if that was up.</summary>
private void OnRendererTapped()
{
Keyboard?.CloseQuickAccess();
input.Acquire();
}
/// <summary>The cursor gesture's arrows, from <see cref="RendererFocus"/>.</summary>
private void OnCursorGesture(ArrowDirection direction, int count) =>
Keyboard?.SendArrow(direction, count);
/// <summary>
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
/// </summary>
@@ -171,6 +372,8 @@ internal sealed partial class TerminalScreen : UserControl
return;
}
RefreshKeys();
var showing = shell.IsTerminalShowing;
if (showing == terminalWasShowing)
@@ -207,7 +410,8 @@ internal sealed partial class TerminalScreen : UserControl
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
/// the tap has to be reported rather than left to the page.
/// </remarks>
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
private void AdoptRenderer() =>
RendererFocus.Suppress(OnRendererTapped, () => Keyboard?.CursorGestures == true, OnCursorGesture);
/// <summary>
/// Puts the keyboard away on the way to the connect menu or the forwards sheet.
@@ -260,129 +464,4 @@ internal sealed partial class TerminalScreen : UserControl
android.BuiltInZoomControls = false;
}
}
private void BuildAccessoryRow()
{
// ── nothing to hand back any more ──────────────────────────────────────────────────────────────
// This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
// the platform requests Android's own focus for Avalonia's view after dispatching each handled
// touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
// TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
// the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
// stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
}
/// <summary>Puts one table's worth of keys into one row of the keypad.</summary>
/// <remarks>
/// The rows differ in what they hold and in nothing else — same control, same class, same wiring — so
/// they are filled by one method rather than by two that would drift.
/// </remarks>
private void Fill(UniformGrid row, (string Label, byte[] Bytes, bool Latches)[] keys)
{
foreach (var (label, bytes, latches) in keys)
{
var key = CreateKey(label);
if (latches)
{
controlKey = key;
key.Click += (_, _) => ToggleControl();
}
else
{
key.Click += (_, _) => SendAsync(bytes);
}
row.Children.Add(key);
}
}
/// <summary>One key of the accessory row, before its click is wired.</summary>
/// <remarks>Split from <see cref="BuildAccessoryRow"/> for length rather than for reuse.</remarks>
private static Button CreateKey(string label) =>
new()
{
Content = label,
// ◆ Every dimension and brush this used to set by hand is now Button.hotkey in Phone.axaml,
// and the move is what lets the row have a pressed state at all: a style can say :pressed and
// an assignment cannot. It also takes this file out of the business of naming the palette's
// brushes, which is how a keypad ends up with a grey nothing else uses.
//
// Content is the string rather than a TextBlock built here for the same reason — the class
// carries the font and the alignment, so a wrapper element would only be somewhere for them to
// disagree.
Classes = { "hotkey" },
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
//
// These keys are an extension of the keyboard, not a place the keyboard should go. As
// ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
// terminal's input target is — and a modifier row that dismisses the keyboard every time it
// is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
// the focused element never changes: TerminalInputSurface is still it, so the input
// connection is never torn down and nothing has to be handed back.
//
// This used to be only half a fix, because the target was the NativeWebView and Android keeps
// a focus of its own that the platform hands to Avalonia's view after every touch it
// dispatches. That half is gone rather than countered: under ADR 0015 the input target is an
// Avalonia control, so the focus Android moves is already where the keyboard should be.
Focusable = false,
};
private void ToggleControl()
{
controlLatched = !controlLatched;
if (controlKey is null)
{
return;
}
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
// ^L to a database prompt believing they typed an l.
//
// A class rather than two brush assignments, so what "latched" looks like is stated once in
// Phone.axaml beside what "pressed" looks like — which is where somebody changing either will be.
controlKey.Classes.Set("latched", controlLatched);
}
/// <remarks>
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
/// 0x1A, which is letter &amp; 0x1F. Applied only to letters, because Ctrl with an arrow key is a
/// different sequence entirely and silently mangling one into a control byte would be worse than
/// ignoring the latch.
/// </remarks>
private void SendAsync(byte[] bytes)
{
if (DataContext is not MainWindowViewModel { SelectedTab: { } tab } shell)
{
return;
}
var payload = bytes;
if (controlLatched && bytes.Length == 1)
{
var c = bytes[0];
if (c is >= (byte)'a' and <= (byte)'z' or >= (byte)'A' and <= (byte)'Z')
{
payload = [(byte)(c & 0x1F)];
}
ToggleControl();
}
// Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has
// gone, and a button handler that awaited would serialise the row behind a slow link.
_ = shell.SendTerminalInputAsync(tab.SessionId, payload).AsTask();
}
/// <summary>Sends a literal string, for the keys that carry text rather than a control code.</summary>
/// <remarks>Kept because the snippet feature will type into a terminal exactly this way.</remarks>
internal void Send(string text) => SendAsync(Encoding.UTF8.GetBytes(text));
}
+4
View File
@@ -48,6 +48,10 @@ internal sealed partial class DodoSshApp : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Recorded, not handled: see CrashLog for why the process still goes.
Avalonia.Threading.Dispatcher.UIThread.UnhandledException +=
(_, e) => CrashLog.RecordFromTheUiThread(e.Exception);
ListenForOpenedLinks();
ShowSomethingWhileComposing(desktop);
}
@@ -0,0 +1,85 @@
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Writes down why the application died, in the profile, before it does.
/// </summary>
/// <remarks>
/// <para>
/// An exception nothing caught used to close the window and leave nothing behind — no file, no event, no
/// sentence — so the only report anybody could make was "it closed". This does not keep the process alive:
/// an exception that reached the top is one no code was written to recover from, and carrying on in whatever
/// state it left would be a guess. What it adds is the record.
/// </para>
/// <para>
/// Bounded, and appended to rather than rewritten, so a run of crashes keeps its history without the file
/// growing for ever. Every write is best-effort: a profile that cannot be written to is a reason to lose the
/// log, never a reason to fail harder.
/// </para>
/// </remarks>
internal static class CrashLog
{
/// <summary>Past this, the next entry starts a fresh file.</summary>
private const long MaxBytes = 1024 * 1024;
private static readonly Lock Gate = new();
private static string? file;
/// <summary>Starts recording, for the process's two last-chance hooks.</summary>
/// <remarks>
/// The UI thread's own hook is added by the application once Avalonia is running — see
/// <see cref="RecordFromTheUiThread"/> — because the dispatcher must not be touched before the platform
/// has set it up.
/// </remarks>
internal static void Install(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
file = paths.CrashLogFile;
AppDomain.CurrentDomain.UnhandledException += (_, e) => Write("Unhandled", e.ExceptionObject as Exception);
// A fire-and-forget task that faulted. It does not end the process, which is exactly why it needs
// writing down: the work it was doing simply never happened, and nothing else would ever say so.
TaskScheduler.UnobservedTaskException += (_, e) =>
{
Write("Unobserved task", e.Exception);
e.SetObserved();
};
}
/// <summary>The UI thread's hook: records the exception and lets it take the process as before.</summary>
internal static void RecordFromTheUiThread(Exception exception) => Write("UI thread", exception);
/// <summary>Appends one entry; never throws.</summary>
internal static void Write(string where, Exception? exception)
{
if (file is not { } path)
{
return;
}
var entry = $"{TimeProvider.System.GetUtcNow():O} {where}{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}";
lock (Gate)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (File.Exists(path) && new FileInfo(path).Length > MaxBytes)
{
File.Delete(path);
}
File.AppendAllText(path, entry);
}
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
{
// Best-effort, by design: see the type's remarks.
}
}
}
}
@@ -0,0 +1,62 @@
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// The device key store this machine supports, chosen once and off the UI thread.
/// </summary>
/// <remarks>
/// <para>
/// Choosing a store means probing for it, and on Windows the probe is creating an RSA-2048 key inside the TPM
/// and deleting it again — see <see cref="WindowsDeviceKeyStore.IsSupported"/> — which is a slow device doing
/// real work. It used to run synchronously on the UI thread three times over: once to choose the store at
/// startup, again when the unlock card asked whether to offer the gesture, and again after every unlock when
/// the security page asked whether to offer registering. A machine does not gain or lose a TPM while this
/// process runs, so the answer is worked out once, on the thread pool, and every question after it is a
/// lookup.
/// </para>
/// <para>
/// Available means the choice landed on a real store, which is what the probe that chose it established.
/// Everything else waits for the choice and hands over to it — in practice it has long since finished,
/// since nothing reaches for the device key until somebody is at the unlock card.
/// </para>
/// </remarks>
internal sealed class ProbedDeviceKeyStore : IDeviceKeyStore
{
private readonly Task<IDeviceKeyStore> chosen;
/// <summary>Starts choosing at once, on the thread pool.</summary>
internal ProbedDeviceKeyStore(Func<IDeviceKeyStore> choose)
{
ArgumentNullException.ThrowIfNull(choose);
chosen = Task.Run(choose);
}
/// <summary>The store the probe settled on, for a test that needs to know which.</summary>
internal Task<IDeviceKeyStore> Chosen => chosen;
/// <inheritdoc />
public async ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
await chosen.WaitAsync(cancellationToken).ConfigureAwait(false) is not UnavailableDeviceKeyStore;
/// <inheritdoc />
public async ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
{
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
await store.SaveAsync(devicePrivateKey, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
{
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
return await store.TryLoadAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask ForgetAsync(CancellationToken cancellationToken)
{
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
await store.ForgetAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -41,7 +41,12 @@ namespace DodoSSH.Client.App.Platform;
/// </remarks>
public static class DesktopDeviceKeyStores
{
/// <summary>The best store this machine supports.</summary>
/// <summary>The best store this machine supports, chosen on the thread pool.</summary>
/// <remarks>
/// Returns at once: the probing that decides between them is slow — a TPM key created and deleted on
/// Windows — and runs behind <see cref="ProbedDeviceKeyStore"/> rather than here, which is on the UI
/// thread at startup.
/// </remarks>
/// <param name="paths">Where a stored key lives.</param>
/// <param name="askForPin">
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
@@ -57,6 +62,13 @@ public static class DesktopDeviceKeyStores
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(askForPin);
return new ProbedDeviceKeyStore(() => Choose(paths, askForPin));
}
private static IDeviceKeyStore Choose(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
{
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
{
return new WindowsDeviceKeyStore(paths);
+3
View File
@@ -75,6 +75,9 @@ internal static class Program
LaunchLink = link;
// After the claim, so a second launch that only hands its link over writes nothing.
CrashLog.Install(paths);
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
@@ -0,0 +1,54 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Tells a drawn titlebar's double-click from a press that starts a drag.
/// </summary>
/// <remarks>
/// <para>
/// Both titlebars used to answer a double-click through <c>DoubleTapped</c> and start a drag on every press.
/// The two do not mix: <c>BeginMoveDrag</c> hands the press to the operating system's own move loop, which
/// eats the release — and a double tap is a gesture Avalonia assembles from presses <em>and</em> releases,
/// so the second click often never completed one and the window did not maximise. The convention it was
/// imitating is one nobody thinks about until it does not work.
/// </para>
/// <para>
/// So the press decides, before the drag begins: a second press inside the platform's double-click time and
/// distance is the maximise, and does not start a drag at all. The click count is honoured where the platform
/// reports one, and the time and distance are measured here for where the move loop kept it from counting.
/// </para>
/// </remarks>
internal sealed class CaptionPresses
{
private ulong lastAt;
private Point lastWhere;
private bool waitingForASecond;
/// <summary>Whether this press is the second of a double-click on <paramref name="bar"/>.</summary>
internal bool IsSecond(PointerPressedEventArgs e, Visual bar)
{
var settings = Application.Current?.PlatformSettings;
var time = settings?.GetDoubleTapTime(e.Pointer.Type) ?? TimeSpan.FromMilliseconds(500);
var size = settings?.GetDoubleTapSize(e.Pointer.Type) ?? new Size(4, 4);
var where = e.GetPosition(bar);
var second = e.ClickCount == 2
|| (waitingForASecond
&& e.Timestamp - lastAt <= (ulong)time.TotalMilliseconds
&& Math.Abs(where.X - lastWhere.X) <= size.Width
&& Math.Abs(where.Y - lastWhere.Y) <= size.Height);
// A double-click spends both presses: a third that follows quickly starts again rather than toggling
// the window straight back.
waitingForASecond = !second;
lastAt = e.Timestamp;
lastWhere = where;
return second;
}
}
@@ -253,8 +253,20 @@
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
<ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerRowViewModel">
<Border Classes="importrow" CornerRadius="8">
@@ -290,8 +302,7 @@
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</ItemsControl>
</Grid>
</Border>
@@ -230,9 +230,13 @@
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
who has found this box should know about the one that also connects on Enter.
One way, and passed back by the code-behind once the typing pauses: every change to the filter
rebuilds the board's sections and their cards, and a name typed at speed used to do that once a
letter. The view model still filters the moment it is told. See HostsScreen.OnFilterTyped.
-->
<Border Grid.Row="1" Margin="26,0,26,16">
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10"
<TextBox x:Name="HostFilter" Text="{Binding HostFilter, Mode=OneWay}" Height="40" CornerRadius="10"
FontFamily="{StaticResource MonoFont}"
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
</Border>
@@ -573,8 +577,9 @@
SelectedItem="{Binding #Board.((vm:VaultViewModel)DataContext).SelectedSidebarRow, Mode=OneWay}"
ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set.">
<!-- Builds only the cards near the view; see VirtualizingWrapPanel. -->
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
<ItemsPanelTemplate><views:VirtualizingWrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
<!--
@@ -106,6 +106,48 @@ internal sealed partial class HostsScreen : UserControl
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
WireTheBand();
HostFilter.TextChanged += OnFilterTyped;
}
/// <summary>How long the find box waits for the typing to pause before the board is rebuilt.</summary>
private static readonly TimeSpan FilterPause = TimeSpan.FromMilliseconds(150);
/// <summary>The wait itself, replaceable so a test can say when a pause is over rather than sleep through one.</summary>
internal Func<TimeSpan, Task> Pause { get; set; } = static span => Task.Delay(span);
/// <summary>How many times the find box has changed, so a wait can tell it is no longer the latest.</summary>
private int typed;
/// <summary>Hands the find box's text to the vault once the typing pauses.</summary>
/// <remarks>
/// <para>
/// Every change to the filter rebuilds the board's sections and every card in them, and a name typed at
/// speed used to do that once a letter. Now it is done once, with the whole of what was typed.
/// </para>
/// <para>
/// A wait rather than a binding delay, and on the dispatcher's own queue: the box is bound one way, so a
/// filter the vault sets itself — cleared, or restored — still lands in the box at once, and the
/// change it makes to the box comes back here as the same text, which changes nothing.
/// </para>
/// </remarks>
private void OnFilterTyped(object? sender, TextChangedEventArgs e) =>
_ = PassTheFilterOnAsync(HostFilter.Text ?? string.Empty, ++typed);
private async Task PassTheFilterOnAsync(string text, int keystroke)
{
await Pause(FilterPause).ConfigureAwait(true);
// A later letter has its own wait, and it is the one that speaks.
if (keystroke != typed)
{
return;
}
if (Vault is { } vault && !string.Equals(vault.HostFilter, text, StringComparison.Ordinal))
{
vault.HostFilter = text;
}
}
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
@@ -131,8 +131,20 @@
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
<ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ImportRowViewModel">
<Border Classes="importrow" CornerRadius="8">
@@ -161,8 +173,7 @@
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</ItemsControl>
</Grid>
</Border>
@@ -26,7 +26,7 @@
-->
<Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
PointerPressed="OnDrag">
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
@@ -9,6 +9,8 @@ internal sealed partial class SettingsTitleBar : UserControl
{
public SettingsTitleBar() => InitializeComponent();
private readonly CaptionPresses presses = new();
private Window? Host => TopLevel.GetTopLevel(this) as Window;
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
@@ -19,6 +21,15 @@ internal sealed partial class SettingsTitleBar : UserControl
return;
}
// Decided on the press, before the drag would take it: see CaptionPresses.
if (presses.IsSecond(e, this))
{
OnToggleMaximised(sender, e);
e.Handled = true;
return;
}
Host?.BeginMoveDrag(e);
}
@@ -31,6 +42,7 @@ internal sealed partial class SettingsTitleBar : UserControl
}
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
/// <remarks>The double-click by way of <see cref="OnDrag"/>; see <see cref="CaptionPresses"/>.</remarks>
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{
if (Host is not { } window)
+1 -1
View File
@@ -32,7 +32,7 @@
-->
<Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
PointerPressed="OnDrag">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
+13 -1
View File
@@ -41,6 +41,8 @@ internal sealed partial class TitleBar : UserControl
}
}
private readonly CaptionPresses presses = new();
private Window? Host => TopLevel.GetTopLevel(this) as Window;
/// <remarks>
@@ -55,6 +57,15 @@ internal sealed partial class TitleBar : UserControl
return;
}
// Decided on the press, before the drag would take it: see CaptionPresses.
if (presses.IsSecond(e, this))
{
OnToggleMaximised(sender, e);
e.Handled = true;
return;
}
Host?.BeginMoveDrag(e);
}
@@ -68,7 +79,8 @@ internal sealed partial class TitleBar : UserControl
/// <remarks>
/// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and
/// the one people reach for without thinking about it.
/// the one people reach for without thinking about it. The double-click comes from <see cref="OnDrag"/>
/// rather than <c>DoubleTapped</c>; see <see cref="CaptionPresses"/>.
/// </remarks>
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{
@@ -1190,8 +1190,24 @@
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
</Grid>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Transfers}">
<!--
Virtualised: a folder download queues a row per file, and a stack panel inside an outer scroller
laid out and drew every one of them, thousands deep. The scroller is the control's own so the panel
knows the viewport it has to fill and realises only those rows.
-->
<ItemsControl Grid.Row="2" ItemsSource="{Binding Transfers}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel">
<!--
@@ -1239,7 +1255,6 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
@@ -0,0 +1,383 @@
using System.Collections.Specialized;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// A wrap panel that builds only the items near the part of it on screen.
/// </summary>
/// <remarks>
/// <para>
/// The hosts board is a list per section, each laid out in rows of cards. It was a plain
/// <see cref="WrapPanel"/>, which builds, measures and draws every card on the board whether it is in view or
/// not — some twenty-five controls a card, so a keychain of three hundred hosts took seconds to open and to
/// redraw after a filter was cleared, and a thousand took several times that. Avalonia has no virtualising
/// wrap panel of its own; this is one, for items of a fixed width.
/// </para>
/// <para>
/// <b>Widths are the first item's; heights are per row.</b> A card's width is set by its style, so the
/// number of columns is arithmetic. Its height is not quite — a card wearing many tags grows a line — so each
/// row's height is the tallest card built in it, and a row not yet built is assumed to be as tall as the last
/// one measured. When a row turns out different the rows below it move, which is the ordinary price of
/// estimating, and is only ever seen while scrolling into rows nobody has looked at yet.
/// </para>
/// <para>
/// <b>What is on screen is read from the effective viewport</b>, which is the part of this panel the
/// scroller around the whole board shows — the section's own list never scrolls, so its own scroller has
/// nothing to say. A section scrolled out of view builds nothing at all past the one card measured for size.
/// </para>
/// <para>
/// Everything else a list does is left to the list. Containers come from its generator and are prepared by
/// it, so selection, the card template, the context menu and the band all work on the containers that exist.
/// A container holding the keyboard is never put away while it does, so arrowing past the edge of the view
/// does not drop the focus on the floor.
/// </para>
/// </remarks>
internal sealed class VirtualizingWrapPanel : VirtualizingPanel
{
/// <summary>How far past the visible part, above and below, items are built ahead of being scrolled to.</summary>
private const double Overscan = 240;
/// <summary>What a row is assumed to be before anything in the panel has been measured.</summary>
private const double FallbackRowHeight = 124;
private readonly Dictionary<int, Control> realized = [];
private readonly Dictionary<object, Stack<Control>> recycled = [];
/// <summary>The pool each container this panel made goes back to; items that are their own containers are not in it.</summary>
private readonly Dictionary<Control, object> poolOf = [];
private readonly Dictionary<int, double> rowHeights = [];
private Rect viewport;
private bool viewportKnown;
private Size itemSize;
private int columns = 1;
/// <summary>An item asked to be scrolled to, kept built until a measure has seen it in range.</summary>
private int? pinned;
public VirtualizingWrapPanel()
{
EffectiveViewportChanged += (_, e) =>
{
if (viewportKnown && e.EffectiveViewport == viewport)
{
return;
}
viewport = e.EffectiveViewport;
viewportKnown = true;
InvalidateMeasure();
};
}
/// <inheritdoc />
protected override Size MeasureOverride(Size availableSize)
{
var items = Items;
if (items.Count == 0)
{
RecycleAllExcept(static _ => false);
return default;
}
if (itemSize == default)
{
var first = RealizedOrNew(0);
first.Measure(Size.Infinity);
itemSize = first.DesiredSize;
}
var width = double.IsInfinity(availableSize.Width)
? itemSize.Width * items.Count
: availableSize.Width;
var nowColumns = Math.Max(1, (int)Math.Floor(width / Math.Max(1, itemSize.Width)));
if (nowColumns != columns)
{
columns = nowColumns;
rowHeights.Clear();
}
var tops = RowTops(items.Count);
var (firstIndex, lastIndex) = VisibleRange(tops, items.Count);
RecycleAllExcept(index =>
(index >= firstIndex && index <= lastIndex)
|| index == pinned
|| realized[index].IsKeyboardFocusWithin);
for (var index = firstIndex; index <= lastIndex; index++)
{
var container = RealizedOrNew(index);
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
var row = index / columns;
rowHeights[row] = Math.Max(rowHeights.GetValueOrDefault(row), container.DesiredSize.Height);
}
if (pinned is { } kept && kept >= firstIndex && kept <= lastIndex)
{
pinned = null;
}
tops = RowTops(items.Count);
return new Size(Math.Min(width, columns * itemSize.Width), tops[^1]);
}
/// <inheritdoc />
protected override Size ArrangeOverride(Size finalSize)
{
var tops = RowTops(Items.Count);
foreach (var (index, container) in realized)
{
container.Arrange(ItemRect(index, tops));
}
return finalSize;
}
/// <inheritdoc />
protected override void OnItemsChanged(IReadOnlyList<object?> items, NotifyCollectionChangedEventArgs e)
{
base.OnItemsChanged(items, e);
// Every change is answered as a reset. The board swaps its lists in whole, so an incremental answer
// would be code for a case that does not happen; and indexes, row heights and the pinned item all
// describe a list that is no longer the one being shown.
RecycleAllExcept(static _ => false);
rowHeights.Clear();
pinned = null;
InvalidateMeasure();
}
/// <inheritdoc />
protected override Control? ScrollIntoView(int index)
{
if (index < 0 || index >= Items.Count)
{
return null;
}
var container = RealizedOrNew(index);
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
container.Arrange(ItemRect(index, RowTops(Items.Count)));
pinned = index;
container.BringIntoView();
return container;
}
/// <inheritdoc />
protected override Control? ContainerFromIndex(int index) => realized.GetValueOrDefault(index);
/// <inheritdoc />
protected override int IndexFromContainer(Control container)
{
foreach (var (index, held) in realized)
{
if (ReferenceEquals(held, container))
{
return index;
}
}
return -1;
}
/// <inheritdoc />
protected override IEnumerable<Control>? GetRealizedContainers() => realized.Values;
/// <inheritdoc />
/// <remarks>
/// Left and right step one card, up and down one row of them — the movements the plain wrap panel's own
/// navigation offered, so the board's arrows do what they did, with the card stepped to built on demand.
/// </remarks>
protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap)
{
var count = Items.Count;
if (count == 0)
{
return null;
}
var current = from is Control control ? IndexFromContainer(control) : -1;
var target = direction switch
{
NavigationDirection.First => 0,
NavigationDirection.Last => count - 1,
NavigationDirection.Left or NavigationDirection.Previous => current - 1,
NavigationDirection.Right or NavigationDirection.Next => current + 1,
NavigationDirection.Up => current - columns,
NavigationDirection.Down => current + columns,
_ => -1,
};
if (current < 0 && direction is not (NavigationDirection.First or NavigationDirection.Last))
{
target = 0;
}
if (target < 0 || target >= count)
{
if (!wrap)
{
return null;
}
target = (target % count + count) % count;
}
return ScrollIntoView(target);
}
/// <summary>The top of every row, and one more entry past the last row for the total height.</summary>
private double[] RowTops(int count)
{
var rows = (count + columns - 1) / columns;
var tops = new double[rows + 1];
var estimate = itemSize.Height > 0 ? itemSize.Height : FallbackRowHeight;
for (var row = 0; row < rows; row++)
{
tops[row + 1] = tops[row] + rowHeights.GetValueOrDefault(row, estimate);
}
return tops;
}
/// <summary>The first and last item in the rows that overlap the visible part, with the overscan.</summary>
private (int First, int Last) VisibleRange(double[] tops, int count)
{
// Before the first viewport arrives, a screenful from the top: the panel has to measure to something.
var top = viewportKnown ? viewport.Top - Overscan : 0;
var bottom = viewportKnown ? viewport.Bottom + Overscan : 1200;
var rows = tops.Length - 1;
var firstRow = -1;
var lastRow = -1;
for (var row = 0; row < rows; row++)
{
if (tops[row + 1] < top)
{
continue;
}
if (tops[row] > bottom)
{
break;
}
if (firstRow < 0)
{
firstRow = row;
}
lastRow = row;
}
return firstRow < 0
? (0, -1)
: (firstRow * columns, Math.Min(count - 1, ((lastRow + 1) * columns) - 1));
}
private Rect ItemRect(int index, double[] tops)
{
var row = index / columns;
var column = index % columns;
var height = row + 1 < tops.Length ? tops[row + 1] - tops[row] : itemSize.Height;
return new Rect(column * itemSize.Width, tops[Math.Min(row, tops.Length - 1)], itemSize.Width, height);
}
/// <summary>The container for an item, built or brought back from the pool if it is not already up.</summary>
private Control RealizedOrNew(int index)
{
if (realized.TryGetValue(index, out var existing))
{
return existing;
}
var generator = ItemContainerGenerator!;
var item = Items[index];
Control container;
if (generator.NeedsContainer(item, index, out var recycleKey))
{
var key = recycleKey ?? typeof(object);
if (recycled.TryGetValue(key, out var pool) && pool.TryPop(out var reused))
{
container = reused;
container.IsVisible = true;
}
else
{
container = generator.CreateContainer(item, index, recycleKey);
poolOf[container] = key;
AddInternalChild(container);
}
generator.PrepareItemContainer(container, item, index);
realized[index] = container;
generator.ItemContainerPrepared(container, item, index);
}
else
{
// An item that is its own container: built by whoever made the item, and never pooled.
container = (Control)item!;
AddInternalChild(container);
realized[index] = container;
}
return container;
}
/// <summary>Puts away every built container the predicate does not keep.</summary>
private void RecycleAllExcept(Func<int, bool> keep)
{
foreach (var index in realized.Keys.ToList())
{
if (keep(index))
{
continue;
}
var container = realized[index];
realized.Remove(index);
if (poolOf.TryGetValue(container, out var key))
{
ItemContainerGenerator!.ClearItemContainer(container);
container.IsVisible = false;
if (!recycled.TryGetValue(key, out var pool))
{
recycled[key] = pool = new Stack<Control>();
}
pool.Push(container);
}
else
{
RemoveInternalChild(container);
}
}
}
}
+3 -3
View File
@@ -86,9 +86,9 @@
},
"Velopack": {
"type": "Direct",
"requested": "[1.2.0, )",
"resolved": "1.2.0",
"contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw=="
"requested": "[1.2.161, )",
"resolved": "1.2.161",
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
},
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
@@ -122,6 +122,9 @@ public sealed record ClientPaths(string DataDirectory)
/// </remarks>
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
/// <summary>Where the application writes down why it died, when something nothing caught ends it.</summary>
public string CrashLogFile => Path.Combine(DataDirectory, "crash.log");
/// <summary>Creates the profile directory if it is not there yet.</summary>
/// <remarks>
/// Separate from resolving the path, because resolving must never have a side effect: it is read
+7 -1
View File
@@ -26,10 +26,15 @@ namespace DodoSSH.Client.Session;
/// <param name="SessionSidebarOpen">
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
/// </param>
/// <param name="TerminalKeyboard">
/// How the phone's keys above the keyboard are arranged, or null for the arrangement it ships with. See
/// <see cref="TerminalKeyboardSettings"/>.
/// </param>
public sealed record ClientSettings(
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
bool AutomaticUpdateChecks = true,
bool SessionSidebarOpen = true)
bool SessionSidebarOpen = true,
TerminalKeyboardSettings? TerminalKeyboard = null)
{
/*
A positional record, and the defaults live on the parameters rather than on property initializers.
@@ -213,4 +218,5 @@ public sealed class ClientSettingsStore(ClientPaths paths)
WriteIndented = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(ClientSettings))]
[JsonSerializable(typeof(TerminalKeyboardSettings))]
internal sealed partial class ClientSettingsJsonContext : JsonSerializerContext;
@@ -80,6 +80,9 @@ public sealed class RelayRoute : IConnectionRoute
RelayProtocol.DurationLimitCloseCode =>
"The server relay closed the connection: it reached the longest session the server allows.",
RelayProtocol.AccessRevokedCloseCode =>
"The server relay closed the connection: you no longer have access to this host's vault.",
_ => null,
};
}
@@ -0,0 +1,47 @@
namespace DodoSSH.Client.Session;
/// <summary>
/// How the phone's terminal keys are arranged on this device.
/// </summary>
/// <remarks>
/// <para>
/// <b>Ids, not keys.</b> Every list here names keys and groups by the stable ids in the shell's key catalog
/// (<c>TerminalKeyCatalog</c>) rather than storing what a key sends, so a fix to a predefined key's bytes
/// reaches people who customised their bar before the fix. An id this build does not know — one written by
/// a newer build, or one for a custom key since deleted — is skipped on the way in rather than refused.
/// </para>
/// <para>
/// <b>In <see cref="ClientSettings"/>, so on this device and in plain text.</b> The arrangement suits one
/// screen and one pair of thumbs, which is the same argument the font size makes for not following
/// somebody onto a desktop. Custom keys are keystrokes such as <c>:wq</c>, and the customise screen says
/// outright that this file is not a place for a password.
/// </para>
/// <para>
/// Null lists mean "as shipped" rather than "empty". An empty <see cref="Bar"/> is a choice somebody made,
/// and has to survive a launch as one.
/// </para>
/// </remarks>
/// <param name="Bar">The keys on the bar above the software keyboard, in order.</param>
/// <param name="GroupOrder">The order of the groups on the quick-access keyboard's KEYS tab.</param>
/// <param name="HiddenGroups">Groups turned off on the KEYS tab.</param>
/// <param name="CustomKeys">Keys somebody added for themselves.</param>
/// <param name="QuickAccessTab">The quick-access keyboard's tab last used, which it reopens to.</param>
/// <param name="SidePanelOpen">Whether a wide screen shows the quick-access keyboard beside the terminal.</param>
/// <param name="CursorGestures">Whether long-press-and-drag on the terminal moves the cursor.</param>
public sealed record TerminalKeyboardSettings(
string[]? Bar = null,
string[]? GroupOrder = null,
string[]? HiddenGroups = null,
CustomTerminalKey[]? CustomKeys = null,
string? QuickAccessTab = null,
bool SidePanelOpen = true,
bool CursorGestures = true);
/// <summary>A key somebody added: what it says on it, and what it types.</summary>
/// <param name="Id">Stable, so the bar can refer to it. Always starts <c>custom:</c>.</param>
/// <param name="Label">What the key shows.</param>
/// <param name="Sequence">
/// What it sends, in the notation the customise screen accepts — <c>\e</c>, <c>\r</c>, <c>^C</c> and so on —
/// rather than as bytes, so the file stays readable and the parser stays the one place escapes are decided.
/// </param>
public sealed record CustomTerminalKey(string Id, string Label, string Sequence);
@@ -0,0 +1,171 @@
namespace DodoSSH.Client.Shell.Keyboard;
/// <summary>Which arrow key a cursor gesture is pressing, if any.</summary>
internal enum ArrowDirection
{
None,
Left,
Down,
Up,
Right,
}
/// <summary>
/// Long-press-and-drag on the terminal as a joystick for the arrow keys.
/// </summary>
/// <remarks>
/// <para>
/// <b>A joystick rather than a trackpad, and the difference is what the gesture is for.</b> A trackpad would
/// send one arrow per so many pixels of travel, which runs out of screen long before a long command line
/// runs out of characters, and does nothing at all for the other half of the job — holding Up to walk back
/// through history. Here the finger's distance from where the press landed is a speed, and the key repeats
/// for as long as it is held there: a short way out steps slowly enough to stop on the character wanted, and
/// further out in the same direction shifts up through the second and third speeds.
/// </para>
/// <para>
/// <b>The first step is immediate and the second waits.</b> Crossing the dead zone sends one key at once, so
/// a quick nudge is exactly one character, and the slowest speed then holds off for
/// <see cref="FirstRepeatDelayMs"/> before repeating — the delay every hardware keyboard puts between a
/// keypress and auto-repeat, for the same reason.
/// </para>
/// <para>
/// <b>The axis is sticky.</b> A thumb dragging left does not travel in a straight line, and without some
/// hysteresis a drift upwards of a few pixels would flip the gesture onto Up and walk into history mid-edit.
/// The other axis has to win by <see cref="AxisSwitchRatio"/> to take over.
/// </para>
/// <para>
/// Pure arithmetic over displacements in density-independent pixels and times in milliseconds, which is
/// what lets it be tested without a touchscreen. The platform half — noticing the long press, taking the
/// touch stream away from the page, running the clock — is in the Android head's <c>RendererFocus</c>.
/// </para>
/// </remarks>
internal sealed class ArrowGesture
{
/// <summary>How far from the press the finger can wander before anything is sent, in dp.</summary>
internal const double DeadZone = 16;
/// <summary>Where the second speed starts, in dp from the press.</summary>
internal const double SecondSpeedAt = 64;
/// <summary>Where the third speed starts, in dp from the press.</summary>
internal const double ThirdSpeedAt = 128;
/// <summary>How long the slowest speed waits after its first key before repeating.</summary>
internal const long FirstRepeatDelayMs = 400;
/// <summary>How much the other axis has to dominate before the direction changes axis.</summary>
internal const double AxisSwitchRatio = 1.4;
/// <summary>The most keys one call reports, so a stalled clock cannot come back as a burst.</summary>
private const int MaximumBurst = 3;
private long nextDue;
/// <summary>The key being held, or <see cref="ArrowDirection.None"/> inside the dead zone.</summary>
internal ArrowDirection Direction { get; private set; }
/// <summary>The speed, from 1 to 3, or 0 inside the dead zone.</summary>
internal int Speed { get; private set; }
/// <summary>The interval between repeats at a speed.</summary>
internal static long IntervalMs(int speed) => speed switch
{
1 => 220,
2 => 90,
_ => 35,
};
/// <summary>Takes the finger's new displacement from where the press landed.</summary>
/// <returns>How many keys to send now, in <see cref="Direction"/>.</returns>
internal int Move(double dx, double dy, long nowMs)
{
var direction = DirectionFor(dx, dy);
if (direction is ArrowDirection.None)
{
Direction = ArrowDirection.None;
Speed = 0;
return 0;
}
var along = direction is ArrowDirection.Left or ArrowDirection.Right ? Math.Abs(dx) : Math.Abs(dy);
var speed = along >= ThirdSpeedAt ? 3 : along >= SecondSpeedAt ? 2 : 1;
if (direction != Direction)
{
Direction = direction;
Speed = speed;
nextDue = nowMs + (speed == 1 ? FirstRepeatDelayMs : IntervalMs(speed));
return 1;
}
if (speed > Speed)
{
// Faster takes effect at once rather than after the slower interval already running out.
nextDue = Math.Min(nextDue, nowMs + IntervalMs(speed));
}
Speed = speed;
return Advance(nowMs);
}
/// <summary>Lets time pass with the finger held still.</summary>
/// <returns>How many keys have come due, in <see cref="Direction"/>.</returns>
internal int Advance(long nowMs)
{
if (Direction is ArrowDirection.None)
{
return 0;
}
var interval = IntervalMs(Speed);
var count = 0;
while (nowMs >= nextDue && count < MaximumBurst)
{
count++;
nextDue += interval;
}
if (nowMs >= nextDue)
{
// Behind by more than a burst: drop the backlog rather than owe it.
nextDue = nowMs + interval;
}
return count;
}
/// <summary>Lets go.</summary>
internal void Reset()
{
Direction = ArrowDirection.None;
Speed = 0;
}
private ArrowDirection DirectionFor(double dx, double dy)
{
var ax = Math.Abs(dx);
var ay = Math.Abs(dy);
if (Math.Max(ax, ay) < DeadZone)
{
return ArrowDirection.None;
}
var horizontal = Direction switch
{
ArrowDirection.Left or ArrowDirection.Right => ay <= ax * AxisSwitchRatio,
ArrowDirection.Up or ArrowDirection.Down => ax > ay * AxisSwitchRatio,
_ => ax >= ay,
};
// Screen coordinates: y grows downwards, so a drag towards the top of the screen is Up.
return horizontal
? dx < 0 ? ArrowDirection.Left : ArrowDirection.Right
: dy < 0 ? ArrowDirection.Up : ArrowDirection.Down;
}
}
@@ -0,0 +1,205 @@
using System.Globalization;
using System.Text;
namespace DodoSSH.Client.Shell.Keyboard;
/// <summary>
/// The line the cursor is on, as a terminal would draw it from the last of a session's output.
/// </summary>
/// <remarks>
/// <para>
/// <b>Just enough of a terminal to read back a line being edited</b>, and deliberately no more. Line editors
/// redraw with a handful of moves — backspace, carriage return, cursor left and right, erase to end of line,
/// delete and insert characters — and those are modelled. Everything else that starts with an escape is
/// skipped whole: colours, titles, mode switches. A redraw this does not follow produces a line that does
/// not match, and the history then keeps nothing, which is the direction it is meant to fail in.
/// </para>
/// <para>
/// The tail can start in the middle of a UTF-8 sequence or an escape. The first becomes a replacement
/// character on a line long since scrolled past; the second is a few stray characters at the start of the
/// same line. Neither reaches the last line unless it is the only line, and a prompt is never that short.
/// </para>
/// </remarks>
internal static class EchoedLine
{
/// <summary>Reads the cursor's line out of a session's recent output.</summary>
internal static string LastLine(ReadOnlySpan<byte> tail)
{
var text = Encoding.UTF8.GetString(tail);
var line = new StringBuilder();
var cursor = 0;
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
switch (c)
{
case '\x1b':
i = Escape(text, i, line, ref cursor);
break;
case '\r':
cursor = 0;
break;
case '\n':
line.Clear();
cursor = 0;
break;
case '\b':
cursor = Math.Max(0, cursor - 1);
break;
case < ' ' or '\x7f':
break;
default:
Put(line, ref cursor, c);
break;
}
}
return line.ToString();
}
private static void Put(StringBuilder line, ref int cursor, char c)
{
while (line.Length < cursor)
{
line.Append(' ');
}
if (cursor < line.Length)
{
line[cursor] = c;
}
else
{
line.Append(c);
}
cursor++;
}
/// <summary>Follows one escape sequence starting at <paramref name="at"/>.</summary>
/// <returns>The index of its last character.</returns>
private static int Escape(string text, int at, StringBuilder line, ref int cursor)
{
if (at + 1 >= text.Length)
{
return at;
}
switch (text[at + 1])
{
case '[':
return ControlSequence(text, at + 2, line, ref cursor);
case ']':
// An operating system command — a window title, a hyperlink — ends at BEL or ST.
for (var i = at + 2; i < text.Length; i++)
{
if (text[i] == '\a')
{
return i;
}
if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '\\')
{
return i + 1;
}
}
return text.Length - 1;
default:
// Two-character escapes, and the charset selectors that take one more.
return text[at + 1] is '(' or ')' or '*' or '+' ? Math.Min(at + 2, text.Length - 1) : at + 1;
}
}
private static int ControlSequence(string text, int from, StringBuilder line, ref int cursor)
{
var end = from;
while (end < text.Length && text[end] is < '@' or > '~')
{
end++;
}
if (end >= text.Length)
{
return text.Length - 1;
}
var parameters = text[from..end];
var n = int.TryParse(parameters, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) && parsed > 0
? parsed
: 1;
switch (text[end])
{
case 'C':
cursor += n;
break;
case 'D':
cursor = Math.Max(0, cursor - n);
break;
case 'G':
cursor = n - 1;
break;
case 'K':
EraseInLine(line, cursor, parameters);
break;
case 'P':
if (cursor < line.Length)
{
line.Remove(cursor, Math.Min(n, line.Length - cursor));
}
break;
case '@':
if (cursor < line.Length)
{
line.Insert(cursor, new string(' ', n));
}
break;
}
return end;
}
private static void EraseInLine(StringBuilder line, int cursor, string parameters)
{
switch (parameters)
{
case "" or "0":
if (cursor < line.Length)
{
line.Length = cursor;
}
break;
case "1":
for (var i = 0; i < Math.Min(cursor + 1, line.Length); i++)
{
line[i] = ' ';
}
break;
case "2":
line.Clear();
break;
}
}
}
@@ -0,0 +1,162 @@
using System.Globalization;
using System.Text;
namespace DodoSSH.Client.Shell.Keyboard;
/// <summary>
/// Reads what a custom key should send, in the notation people already write it in.
/// </summary>
/// <remarks>
/// <para>
/// Two notations, both the ones a terminal user has seen before: backslash escapes as <c>printf</c> and
/// <c>bind</c> write them (<c>\e</c>, <c>\r</c>, <c>\t</c>, <c>\n</c>, <c>\\</c>, <c>\xHH</c>), and caret
/// notation as <c>stty</c> and every man page write control characters (<c>^C</c>, <c>^[</c>, <c>^?</c>).
/// Anything else is typed as it stands, in UTF-8.
/// </para>
/// <para>
/// <b>A caret only means Ctrl in front of a character that has a control form</b> — a letter, or one of
/// <c>@[\]^_?</c>. Elsewhere, as in <c>^1</c> or a trailing <c>^</c>, it is a caret. The cost is that
/// <c>grep ^root</c> written here sends Ctrl-R; <c>\^</c> is the way to say a literal caret before a
/// letter, and the customise screen says so.
/// </para>
/// <para>
/// Refusing is reserved for what cannot mean anything — a backslash at the very end, or <c>\x</c> without
/// two hex digits — so a sequence that parses is one that sends exactly what it says.
/// </para>
/// </remarks>
internal static class KeySequence
{
/// <summary>Turns notation into bytes.</summary>
/// <returns>False when the notation is malformed; <paramref name="bytes"/> is then empty.</returns>
internal static bool TryParse(string notation, out byte[] bytes)
{
ArgumentNullException.ThrowIfNull(notation);
var output = new List<byte>(notation.Length);
var literal = new StringBuilder();
for (var i = 0; i < notation.Length; i++)
{
var c = notation[i];
if (c == '\\')
{
if (!TryEscape(notation, ref i, output, literal))
{
bytes = [];
return false;
}
continue;
}
if (c == '^' && i + 1 < notation.Length && TryControl(notation[i + 1], out var control))
{
FlushLiteral(output, literal);
output.Add(control);
i++;
continue;
}
literal.Append(c);
}
FlushLiteral(output, literal);
bytes = [.. output];
return true;
}
/// <summary>Reads the escape whose backslash is at <paramref name="i"/>, leaving it on its last character.</summary>
private static bool TryEscape(string notation, ref int i, List<byte> output, StringBuilder literal)
{
if (i + 1 >= notation.Length)
{
return false;
}
var next = notation[++i];
if (next is 'x' or 'X')
{
// Exactly two digits, always. One digit followed by text would be a guess at where the number
// stops.
if (notation.Length - i - 1 < 2
|| !byte.TryParse(
notation.AsSpan(i + 1, 2),
NumberStyles.AllowHexSpecifier,
CultureInfo.InvariantCulture,
out var hex))
{
return false;
}
FlushLiteral(output, literal);
output.Add(hex);
i += 2;
return true;
}
byte? escaped = next switch
{
'e' or 'E' => 0x1B,
'r' => 0x0D,
'n' => 0x0A,
't' => 0x09,
'b' => 0x08,
'a' => 0x07,
'0' => 0x00,
_ => null,
};
if (escaped is { } value)
{
FlushLiteral(output, literal);
output.Add(value);
}
else
{
// \\, \^ and any other escaped character stand for themselves.
literal.Append(next);
}
return true;
}
/// <summary>Moves the plain text gathered so far into the output, as UTF-8.</summary>
private static void FlushLiteral(List<byte> output, StringBuilder literal)
{
if (literal.Length > 0)
{
output.AddRange(Encoding.UTF8.GetBytes(literal.ToString()));
literal.Clear();
}
}
/// <summary>The control character caret notation names, where there is one.</summary>
/// <remarks>
/// <c>^?</c> is DEL rather than a masked byte — the one exception every table of caret notation makes,
/// and the one people reach for as "backspace".
/// </remarks>
internal static bool TryControl(char c, out byte control)
{
if (c == '?')
{
control = 0x7F;
return true;
}
var upper = char.ToUpperInvariant(c);
if (upper is >= '@' and <= '_')
{
control = (byte)(upper & 0x1F);
return true;
}
control = 0;
return false;
}
}
@@ -0,0 +1,55 @@
namespace DodoSSH.Client.Shell.Keyboard;
/// <summary>What pressing a terminal key does.</summary>
internal enum TerminalKeyKind
{
/// <summary>Sends its bytes, after any latched modifier has had its say.</summary>
Send,
/// <summary>Latches Ctrl for the next key, and releases itself after it.</summary>
Control,
/// <summary>Latches Alt for the next key, and releases itself after it.</summary>
Alt,
/// <summary>Pastes the clipboard, through the same path a right-click on the desktop takes.</summary>
Paste,
}
/// <summary>
/// One key the phone's own keyboard does not have.
/// </summary>
/// <remarks>
/// <para>
/// A record for the convenience of construction and nothing else: two keys are the same key when their
/// <see cref="Id"/> is, which is what the bar stores. The byte array would make record equality reference
/// equality anyway, so nothing here leans on it.
/// </para>
/// <para>
/// <see cref="IsWide"/> exists for the labels that are words — <c>ctrl+B %</c> — which want two cells on
/// the quick-access keyboard where an arrow wants one. It is a fact about the label rather than a layout
/// choice made per screen, so it lives here.
/// </para>
/// </remarks>
/// <param name="Id">Stable across releases; the bar and the group order are stored by it.</param>
/// <param name="Label">What the key says.</param>
/// <param name="Kind">What pressing it does.</param>
/// <param name="Bytes">What it sends, for <see cref="TerminalKeyKind.Send"/>. Empty otherwise.</param>
/// <param name="Hint">What it is for, for the customise screen and for accessibility.</param>
internal sealed record TerminalKey(string Id, string Label, TerminalKeyKind Kind, byte[] Bytes, string Hint)
{
/// <summary>Whether the label is long enough to want a double-width cell.</summary>
internal bool IsWide => Label.Length > 4;
/// <summary>Whether this is one of the two latching modifiers.</summary>
internal bool IsModifier => Kind is TerminalKeyKind.Control or TerminalKeyKind.Alt;
/// <summary>Whether this key was added by the person using it rather than shipped.</summary>
internal bool IsCustom => Id.StartsWith(TerminalKeyCatalog.CustomPrefix, StringComparison.Ordinal);
}
/// <summary>A named set of keys on the quick-access keyboard.</summary>
/// <param name="Id">Stable across releases; the order and the hidden set are stored by it.</param>
/// <param name="Title">The caption above the keys.</param>
/// <param name="Keys">The keys, in the order they are drawn.</param>
internal sealed record TerminalKeyGroup(string Id, string Title, IReadOnlyList<TerminalKey> Keys);
@@ -0,0 +1,199 @@
using System.Collections.Frozen;
using System.Text;
namespace DodoSSH.Client.Shell.Keyboard;
/// <summary>
/// Every key the phone ships with, in the groups the quick-access keyboard draws them in.
/// </summary>
/// <remarks>
/// <para>
/// <b>The ids are a stored format.</b> The bar and the group order are written to the settings file by id,
/// so renaming one silently drops it from every bar it was on. Labels, hints and bytes can change freely —
/// that is the point of storing ids — but an id, once shipped, stays.
/// </para>
/// <para>
/// <b>Cursor and function keys are written as the sequences xterm sends in its default mode</b> — CSI for
/// the arrows, SS3 for F1 to F4 — for the reason <c>TerminalInputSurface</c> gives: nothing on this side
/// tracks the remote's application-cursor mode, and CSI is what bash, zsh and every readline accept in
/// both modes.
/// </para>
/// </remarks>
internal static class TerminalKeyCatalog
{
/// <summary>What every custom key's id starts with, so one can never collide with a shipped key.</summary>
internal const string CustomPrefix = "custom:";
/// <summary>The id of the group custom keys are drawn in.</summary>
internal const string CustomGroupId = "custom";
/// <summary>The most keys the bar holds.</summary>
/// <remarks>
/// Two rows of <see cref="BarRowWidth"/>. A third row would cost the terminal another five lines on a
/// phone, and anything used rarely enough not to make the first sixteen belongs on the quick-access
/// keyboard, one tap further away.
/// </remarks>
internal const int MaximumBarKeys = 16;
/// <summary>The most keys one row of the bar divides its width between.</summary>
/// <remarks>
/// Eight across 360dp is a 41dp target after margins — just under Android's 48 and roughly what every
/// terminal on the platform ships. Nine would be 36, which is where the old scrolling row's misses began.
/// </remarks>
internal const int BarRowWidth = 8;
private static TerminalKey Send(string id, string label, string hint, params byte[] bytes) =>
new(id, label, TerminalKeyKind.Send, bytes, hint);
private static TerminalKey Text(string id, string label, string hint, string text) =>
new(id, label, TerminalKeyKind.Send, Encoding.UTF8.GetBytes(text), hint);
private static byte[] Csi(string tail) => [0x1B, (byte)'[', .. Encoding.ASCII.GetBytes(tail)];
private static byte[] Ss3(char final) => [0x1B, (byte)'O', (byte)final];
/// <summary>Ctrl-B and then a key: one press of a tmux binding under the default prefix.</summary>
private static TerminalKey Tmux(string id, string key, string hint) =>
new(id, $"ctrl+B {key}", TerminalKeyKind.Send, [0x02, .. Encoding.ASCII.GetBytes(key)], hint);
/// <summary>
/// Escape first, then the command: whatever mode vi was in, this is the one it is meant for.
/// </summary>
private static TerminalKey Vim(string id, string label, string command, string hint) =>
new(id, label, TerminalKeyKind.Send, [0x1B, .. Encoding.ASCII.GetBytes(command)], hint);
private static TerminalKey Control(char letter) =>
Send(
$"ctrl-{char.ToLowerInvariant(letter)}",
$"^{letter}",
ControlHints.GetValueOrDefault(letter, $"Ctrl+{letter}"),
(byte)(letter & 0x1F));
private static readonly Dictionary<char, string> ControlHints = new()
{
['C'] = "Interrupt the running command",
['D'] = "End of input; logs out at an empty prompt",
['Z'] = "Suspend the running command",
['L'] = "Clear the screen",
['R'] = "Search command history",
['A'] = "Start of the line",
['E'] = "End of the line",
['U'] = "Delete to the start of the line",
['W'] = "Delete the word before the cursor",
['K'] = "Delete to the end of the line",
};
/// <summary>The groups as shipped, in their shipped order.</summary>
internal static IReadOnlyList<TerminalKeyGroup> Groups { get; } =
[
new("essentials", "ESSENTIALS",
[
Send("esc", "esc", "Escape", 0x1B),
Send("tab", "tab", "Complete a command or path", 0x09),
Send("shift-tab", "shift+tab", "Backwards through completions and forms", Csi("Z")),
new("ctrl", "ctrl", TerminalKeyKind.Control, [], "Hold Ctrl for the next key"),
new("alt", "alt", TerminalKeyKind.Alt, [], "Hold Alt for the next key"),
Send("enter", "⏎", "Enter", 0x0D),
new("paste", "paste", TerminalKeyKind.Paste, [], "Paste the clipboard"),
Send("ins", "ins", "Insert", Csi("2~")),
Send("del", "del", "Delete forwards", Csi("3~")),
]),
new("cursor", "CURSOR",
[
Send("left", "←", "Left", Csi("D")),
Send("down", "↓", "Down; the next command in history", Csi("B")),
Send("up", "↑", "Up; the previous command in history", Csi("A")),
Send("right", "→", "Right", Csi("C")),
Send("home", "home", "Start of the line", Csi("H")),
Send("end", "end", "End of the line", Csi("F")),
Send("pgup", "pgup", "Page up", Csi("5~")),
Send("pgdn", "pgdn", "Page down", Csi("6~")),
]),
new("control", "CONTROL",
[
Control('C'), Control('D'), Control('Z'), Control('L'), Control('R'),
Control('A'), Control('E'), Control('U'), Control('W'), Control('K'),
]),
new("symbols", "SYMBOLS",
[
Text("pipe", "|", "Pipe", "|"),
Text("dash", "-", "Hyphen", "-"),
Text("underscore", "_", "Underscore", "_"),
Text("tilde", "~", "Home directory", "~"),
Text("slash", "/", "Slash", "/"),
Text("backslash", "\\", "Backslash", "\\"),
Text("backtick", "`", "Backtick", "`"),
Text("star", "*", "Asterisk", "*"),
Text("amp", "&", "Ampersand", "&"),
Text("dollar", "$", "Dollar", "$"),
Text("lt", "<", "Less than", "<"),
Text("gt", ">", "Greater than", ">"),
Text("lbrace", "{", "Left brace", "{"),
Text("rbrace", "}", "Right brace", "}"),
Text("lbracket", "[", "Left bracket", "["),
Text("rbracket", "]", "Right bracket", "]"),
]),
new("tmux", "TMUX",
[
Send("tmux-prefix", "^B", "The tmux prefix", 0x02),
Tmux("tmux-new", "c", "New window"),
Tmux("tmux-next", "n", "Next window"),
Tmux("tmux-prev", "p", "Previous window"),
Tmux("tmux-split-v", "%", "Split side by side"),
Tmux("tmux-split-h", "\"", "Split top and bottom"),
Tmux("tmux-other", "o", "Next pane"),
Tmux("tmux-kill", "x", "Close the pane"),
Tmux("tmux-detach", "d", "Detach"),
Tmux("tmux-copy", "[", "Scroll and copy mode"),
]),
new("vim", "VIM",
[
Vim("vim-write", ":w", ":w\r", "Save"),
Vim("vim-quit", ":q", ":q\r", "Quit"),
Vim("vim-write-quit", ":wq", ":wq\r", "Save and quit"),
Vim("vim-force-quit", ":q!", ":q!\r", "Quit without saving"),
Vim("vim-top", "gg", "gg", "First line"),
Vim("vim-bottom", "G", "G", "Last line"),
]),
new("function", "FUNCTION",
[
Send("f1", "F1", "F1", Ss3('P')),
Send("f2", "F2", "F2", Ss3('Q')),
Send("f3", "F3", "F3", Ss3('R')),
Send("f4", "F4", "F4", Ss3('S')),
Send("f5", "F5", "F5", Csi("15~")),
Send("f6", "F6", "F6", Csi("17~")),
Send("f7", "F7", "F7", Csi("18~")),
Send("f8", "F8", "F8", Csi("19~")),
Send("f9", "F9", "F9", Csi("20~")),
Send("f10", "F10", "F10", Csi("21~")),
Send("f11", "F11", "F11", Csi("23~")),
Send("f12", "F12", "F12", Csi("24~")),
]),
];
/// <summary>
/// The bar as shipped: the keys this head's old two fixed rows carried, plus Alt and paste.
/// </summary>
/// <remarks>
/// Modifiers and punctuation on the first row, cursor keys on the second — the split the old keypad's
/// remarks argue for, which is unchanged: the arrows are the keys pressed repeatedly and in sequence, so
/// they get the row nearest the thumb and, being fewer, the widest targets. Paste joins them because a
/// long press on the terminal is now the cursor gesture, and was the only way to paste before.
/// </remarks>
internal static IReadOnlyList<string> DefaultBar { get; } =
[
"esc", "tab", "ctrl", "alt", "pipe", "dash", "slash", "tilde",
"left", "down", "up", "right", "paste",
];
/// <summary>Every shipped key by id.</summary>
internal static FrozenDictionary<string, TerminalKey> ById { get; } =
Groups.SelectMany(g => g.Keys).ToFrozenDictionary(k => k.Id, StringComparer.Ordinal);
/// <summary>Builds the key a stored custom entry describes, or null when its sequence does not parse.</summary>
internal static TerminalKey? FromCustom(string id, string label, string sequence) =>
KeySequence.TryParse(sequence, out var bytes) && bytes.Length > 0
? new TerminalKey(id, label, TerminalKeyKind.Send, bytes, $"Sends {sequence}")
: null;
}
@@ -0,0 +1,57 @@
namespace DodoSSH.Client.Shell.Keyboard;
/// <summary>
/// What a latched Ctrl or Alt does to the key pressed after it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The encodings xterm uses, and nothing cleverer.</b> Ctrl with a character is that character's
/// control form — <c>c &amp; 0x1F</c>, the rule caret notation names. Alt is an Escape in front, which is
/// what readline's <c>M-b</c> and <c>M-f</c> and every Emacs binding expect from a terminal. Ctrl or Alt with
/// a cursor key is the modified CSI form, <c>CSI 1 ; m X</c>, which is how Ctrl+← jumps a word in bash and
/// zsh.
/// </para>
/// <para>
/// <b>Anything else goes through untouched.</b> Ctrl with a pasted line or with a function key has no
/// encoding a shell agrees on, and mangling one into something else would be worse than ignoring the
/// latch. The latch still releases, because the person pressed the key it was latched for.
/// </para>
/// </remarks>
internal static class TerminalModifiers
{
/// <summary>Applies whichever modifiers are latched to one key's worth of bytes.</summary>
internal static byte[] Apply(byte[] payload, bool control, bool alt)
{
ArgumentNullException.ThrowIfNull(payload);
if (!control && !alt)
{
return payload;
}
// ESC [ X for the six cursor keys that have a modified form: the arrows, Home and End.
if (payload is [0x1B, (byte)'[', var final] && final is (byte)'A' or (byte)'B' or (byte)'C'
or (byte)'D' or (byte)'H' or (byte)'F')
{
var modifier = 1 + (alt ? 2 : 0) + (control ? 4 : 0);
return [0x1B, (byte)'[', (byte)'1', (byte)';', (byte)('0' + modifier), final];
}
var result = payload;
if (control && payload is [var single])
{
result = [ControlForm(single)];
}
return alt ? [0x1B, .. result] : result;
}
/// <summary>The control character for one byte, or the byte itself where there is none.</summary>
/// <remarks>Space as NUL is the one addition to caret notation, and every terminal makes it.</remarks>
private static byte ControlForm(byte c) =>
c == (byte)' ' ? (byte)0x00
: c < 0x80 && KeySequence.TryControl((char)c, out var control) ? control
: c;
}
@@ -0,0 +1,224 @@
using System.Collections.ObjectModel;
using System.Text;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Shell.Keyboard;
/// <summary>One line the history kept: what ran, and where.</summary>
internal sealed record HistoryEntry(string Command, string Host);
/// <summary>
/// The commands typed on this phone, for the quick-access keyboard's HISTORY tab.
/// </summary>
/// <remarks>
/// <para>
/// <b>Only what the remote echoed, and that is the whole safety story.</b> A terminal is one input stream,
/// and a <c>sudo</c> password typed into it looks exactly like a command typed into it — right up to the
/// moment the shell does or does not echo it. So nothing is kept on the strength of what was typed. On Enter
/// the line the cursor is on is read back out of the session's own output (see <see cref="EchoedLine"/>) and
/// a command is kept only if it is there: a password prompt, which echoes nothing, leaves the line as the
/// prompt alone and nothing is recorded. Full-screen programs are skipped outright, because "the line the
/// cursor is on" in vi is a line of a file.
/// </para>
/// <para>
/// <b>Two ways to read the command back, the second stricter than the first.</b> The prompt is noted when the
/// first key of a line is pressed; if the line on Enter still starts with it, what follows is the command —
/// which is what lets a line finished with Tab completion or recalled with ↑ be kept as the shell ran it.
/// When the prompt has moved (a redraw, a multi-line prompt), the line is kept only if it was typed key by
/// key with nothing the client cannot follow, and the echo ends with exactly that text.
/// </para>
/// <para>
/// <b>Memory only, and cleared on lock.</b> Commands carry hostnames, paths and sometimes tokens on the
/// command line. The settings file is plaintext and the cache is the vault's, so this is neither: it lasts
/// as long as the process, the LOCK button empties it, and the tab says so.
/// </para>
/// <para>
/// A line that fails any test is simply not kept. A history that misses a command costs one retype; one
/// that kept a password would be showing it to whoever looks at the phone next.
/// </para>
/// </remarks>
internal sealed class TypedCommandHistory
{
/// <summary>The most commands kept. Older ones fall off the end.</summary>
internal const int Capacity = 100;
/// <summary>The longest command kept; anything longer is not something to retype from a list.</summary>
internal const int MaximumLength = 512;
private readonly Dictionary<uint, LineInProgress> lines = [];
/// <summary>Most recent first, without duplicates.</summary>
internal ObservableCollection<HistoryEntry> Entries { get; } = [];
/// <summary>Notes one key's worth of bytes about to be sent to a session.</summary>
/// <param name="sessionId">The session they are going to.</param>
/// <param name="host">What the tab is called, to label an entry with.</param>
/// <param name="sent">The bytes, after any latched modifier — what the remote will actually see.</param>
/// <param name="recent">
/// The session's recent output. Called before the bytes go, so on Enter it holds the echo of the line and
/// not yet the command's output.
/// </param>
internal void Observe(uint sessionId, string host, ReadOnlySpan<byte> sent, Func<RecentOutput?> recent)
{
ArgumentNullException.ThrowIfNull(recent);
if (!lines.TryGetValue(sessionId, out var line))
{
line = new LineInProgress();
lines[sessionId] = line;
}
foreach (var c in Encoding.UTF8.GetString(sent))
{
if (c is '\r' or '\n')
{
Commit(line, host, recent);
line.Reset();
continue;
}
if (!line.Started)
{
line.Begin(recent());
}
switch (c)
{
case '\x7f' or '\b':
if (line.Typed.Length > 0)
{
line.Typed.Length--;
}
break;
case '\x03' or '\x15':
// Ctrl-C abandons the line, Ctrl-U empties it; either way the next key starts afresh.
line.Reset();
break;
case '\x1b':
// A cursor key, Alt, a function key: the rest of these bytes are the sequence, and what
// the line now holds is the shell's business. The prompt route can still read it back.
line.Tainted = true;
return;
case < ' ':
// Tab, Ctrl-R, Ctrl-W and the rest: the line changed in a way not followed here.
line.Tainted = true;
break;
default:
line.Typed.Append(c);
break;
}
}
}
/// <summary>
/// Notes that text is about to be typed into a session by something other than its keys — a history
/// entry or a snippet — so that the Enter after it can still be read back.
/// </summary>
internal void NoteInserted(uint sessionId, Func<RecentOutput?> recent)
{
ArgumentNullException.ThrowIfNull(recent);
if (!lines.TryGetValue(sessionId, out var line))
{
line = new LineInProgress();
lines[sessionId] = line;
}
if (!line.Started)
{
line.Begin(recent());
}
// Not typed key by key, so only the prompt route may keep it.
line.Tainted = true;
}
/// <summary>Drops what was being typed into a session that has gone.</summary>
internal void Forget(uint sessionId) => lines.Remove(sessionId);
/// <summary>Forgets everything, for LOCK and for the tab's own CLEAR.</summary>
internal void Clear()
{
Entries.Clear();
lines.Clear();
}
private void Commit(LineInProgress line, string host, Func<RecentOutput?> recent)
{
if (!line.Started || recent() is not { AlternateScreen: false } output)
{
return;
}
var shown = EchoedLine.LastLine(output.Tail);
var typed = line.Typed.ToString().Trim();
string? command = null;
if (line.Prompt is { Length: > 0 } prompt
&& shown.StartsWith(prompt, StringComparison.Ordinal)
&& shown[prompt.Length..].Trim() is { Length: > 0 } afterPrompt)
{
// Typed key by key, the echo has to be exactly what was typed — a prompt that echoes a star per
// character is still not a command. Tainted, the echo is the only account of the line there is.
command = line.Tainted || string.Equals(afterPrompt, typed, StringComparison.Ordinal) ? afterPrompt : null;
}
else if (!line.Tainted && typed.Length > 0 && shown.TrimEnd().EndsWith(typed, StringComparison.Ordinal))
{
command = typed;
}
if (command is null || command.Length > MaximumLength || !command.Any(char.IsLetterOrDigit))
{
return;
}
for (var i = Entries.Count - 1; i >= 0; i--)
{
if (string.Equals(Entries[i].Command, command, StringComparison.Ordinal))
{
Entries.RemoveAt(i);
}
}
Entries.Insert(0, new HistoryEntry(command, host));
while (Entries.Count > Capacity)
{
Entries.RemoveAt(Entries.Count - 1);
}
}
private sealed class LineInProgress
{
internal StringBuilder Typed { get; } = new();
/// <summary>What the cursor's line said before the first key of this line.</summary>
internal string? Prompt { get; private set; }
internal bool Started { get; private set; }
/// <summary>Whether something not followed here changed the line.</summary>
internal bool Tainted { get; set; }
internal void Begin(RecentOutput? output)
{
Started = true;
Prompt = output is { AlternateScreen: false } ? EchoedLine.LastLine(output.Tail).TrimEnd() : null;
}
internal void Reset()
{
Typed.Clear();
Prompt = null;
Started = false;
Tainted = false;
}
}
}
@@ -332,6 +332,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly ClientSettingsStore settings;
/// <summary>
/// The phone's keys for a terminal: the bar, the quick-access keyboard, and how both are arranged.
/// </summary>
internal TerminalKeyboardViewModel Keyboard { get; }
/// <remarks>
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
@@ -641,6 +646,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.workspace.RendererReattached += OnRendererReattached;
settings = new ClientSettingsStore(paths);
Keyboard = CreateKeyboard();
updateScreen = CreateUpdateScreen(updates);
@@ -651,6 +657,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
StartSessionShellTracking();
}
/// <summary>The phone's keys, wired to this shell's sessions.</summary>
/// <remarks>
/// Built on every head because it is only state and a settings read, and the desktop simply never draws
/// it; a head-specific view model would be one more thing this constructor had to be told about per
/// platform. The customise sheet takes the screen the way the connect sheet does, so it moves the same
/// flag — see <see cref="IsTerminalShowing"/>.
/// </remarks>
private TerminalKeyboardViewModel CreateKeyboard()
{
var keyboard = new TerminalKeyboardViewModel(
settings,
CurrentTypingTarget,
SendTerminalInputAsync,
workspace.GetRecentOutput,
workspace.PasteAsync,
PasteIntoTerminalAsync);
keyboard.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(TerminalKeyboardViewModel.IsCustomizing))
{
RaiseTerminalState();
}
};
return keyboard;
}
/// <summary>
/// Takes this machine's own preferences off disk, before anything can read them.
/// </summary>
@@ -1500,6 +1534,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
IsTerminalSurface
&& !IsConnectSheetOpen
&& !IsForwardsSheetOpen
&& !Keyboard.IsCustomizing
&& !IsHostKeyDecisionShowing
&& SelectedTab is { HasSession: true };
@@ -2133,12 +2168,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return;
}
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
.ConfigureAwait(true);
try
{
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
.ConfigureAwait(true);
// The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing it
// second means the menu and the screen behind it never disagree, even for a frame.
await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
// The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing
// it second means the menu and the screen behind it never disagree, even for a frame.
await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// A command's exception is rethrown on the UI thread, where nothing catches it — so a cache that
// could not be written to, or a vault locked halfway through, used to close the application. The
// switches are redrawn from what is actually stored, so the menu does not claim the change.
RebuildVaultToggles();
StatusMessage = $"Could not change which vaults are shown: {exception.Message}";
return;
}
RebuildVaultToggles();
@@ -2950,13 +2998,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
{
// ◆ Which lock this unlock started under. Every await below is a moment the user can press Lock, and
// LockAsync can only close what has been handed over by then — so an unlock still attaching its
// stores used to finish after the lock and open the vault behind the lock screen.
var generation = locks;
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
// Before the vault view model, because that is what reads it — and read at all rather than defaulted
// to "everything shown", because a vault somebody set aside last week should still be set aside.
visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true);
Vault = new VaultViewModel(
if (generation != locks)
{
await AbandonAsync(session).ConfigureAwait(true);
return;
}
var opened = Vault = new VaultViewModel(
session,
workspace,
knownHosts,
@@ -2980,9 +3039,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// are both !CanRegisterDevice, and only the second has anything to withdraw.
CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
await OpenAsync(opened, generation, cancellationToken).ConfigureAwait(true);
}
AfterTheFirstLoad(Vault);
/// <summary>Loads the vault just handed over, and starts it syncing — unless a lock has closed it since.</summary>
/// <remarks>
/// From here the vault belongs to the shell, so a lock closes it itself: there is nothing to undo, only
/// nothing more to do to a vault that is gone. Before these checks, a lock pressed while the first load
/// ran left this to carry on into a disposed session and a vault the shell no longer held.
/// </remarks>
private async Task OpenAsync(VaultViewModel opened, int generation, CancellationToken cancellationToken)
{
if (generation != locks)
{
return;
}
await opened.LoadAsync(cancellationToken).ConfigureAwait(true);
if (generation != locks)
{
return;
}
AfterTheFirstLoad(opened);
// After the first load, so the list is on screen before anything talks to a server. The loop is
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
@@ -2997,7 +3077,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// and unlocking must never wait on one. Everything the unlock screen promises about working
// offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
// the titlebar says OFFLINE until the round trip this starts has an answer.
Vault.StartAutoSync();
opened.StartAutoSync();
}
/// <summary>Puts away an unlock that a lock overtook before the vault was handed over.</summary>
/// <remarks>
/// Everything <see cref="LockAsync"/> would have closed had it been here to close: the stores attached to
/// this session, the switches read from it, and the session itself, whose keys nothing else now holds.
/// </remarks>
private async Task AbandonAsync(VaultSession session)
{
knownHosts.Close();
connectionLog.Close();
visibility = null;
await session.DisposeAsync().ConfigureAwait(true);
}
/// <summary>Everything an unlock does that needs the vault's lists to have been read.</summary>
@@ -3259,6 +3353,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private async Task LockAsync()
{
// Counted first, so an unlock still on its way in sees it at its next step and stands down rather than
// opening the vault after this has run. See AdoptAsync.
locks++;
// First, and before the session it read from goes: a synchronisation pass may be in flight, and it
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
// reappearing behind a lock screen.
@@ -3306,6 +3404,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private bool isConfirmingSignOut;
/// <summary>How many times this shell has been locked, for an unlock to notice it was overtaken.</summary>
private int locks;
/// <summary>
/// What signing out costs, on this machine, right now.
/// </summary>
@@ -3749,6 +3850,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
? null
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
// The commands typed on this phone go with the keychain, for the reason the recent connections below
// do: they are a record of where somebody works and what they ran there, and LOCK is the promise
// that nothing like that is left on screen. See TypedCommandHistory.
if (newValue is null)
{
Keyboard.ClearHistory();
}
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
// Emptied with the vault it was read out of. These rows are decrypted log entries — a host's name
@@ -4164,6 +4273,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
open.HostLabel, open.Address, open.StartedAt, deviceName)),
];
/// <summary>The session the phone's keys type into: the selected tab, once it has a shell.</summary>
private TypingTarget? CurrentTypingTarget() =>
SelectedTab is { HasSession: true } tab ? new TypingTarget(tab.SessionId, tab.Label) : null;
private InsertTarget CurrentInsertTarget() =>
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
@@ -0,0 +1,44 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// An observable list whose whole contents can be swapped and announced once.
/// </summary>
/// <remarks>
/// <para>
/// For the lists rebuilt wholesale from the vault. They used to be emptied with <c>Clear</c> and refilled an
/// <c>Add</c> at a time, which announced every row separately — and two other view models listen to the host
/// list and redo their own work on every announcement: the transfers screen refilled its host picker, and the
/// shell walked every host against every tab. A reload of n hosts was n of each, so roughly n² rows touched;
/// three hundred hosts came to some ninety thousand notifications per reload.
/// </para>
/// <para>
/// One <see cref="NotifyCollectionChangedAction.Reset"/> instead, which is also exactly what <c>Clear</c> has
/// always said first, so a listener that coped with the old sequence copes with this. Nothing listening to
/// these lists reads the items an announcement carries; they all re-read the list.
/// </para>
/// </remarks>
internal sealed class ReplaceableCollection<T> : ObservableCollection<T>
{
/// <summary>Makes <paramref name="items"/> the whole of the list, and says so once.</summary>
internal void ReplaceAll(IEnumerable<T> items)
{
ArgumentNullException.ThrowIfNull(items);
CheckReentrancy();
Items.Clear();
foreach (var item in items)
{
Items.Add(item);
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}
@@ -0,0 +1,825 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.Keyboard;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>The quick-access keyboard's tabs.</summary>
internal enum QuickAccessTab
{
Keys,
Snippets,
History,
Appearance,
}
/// <summary>Where typed bytes are going: a session that is open, and what its tab is called.</summary>
internal sealed record TypingTarget(uint SessionId, string Host);
/// <summary>
/// The phone's keys for a terminal: the bar above the software keyboard, the quick-access keyboard that can
/// take its place, the customise screen, and the cursor gesture's switch.
/// </summary>
/// <remarks>
/// <para>
/// <b>Here rather than in the Android head, because almost none of it is about Android.</b> Which keys exist,
/// how they are arranged, what a latched Ctrl does to the next one and what the history keeps are questions
/// with right answers that can be tested on any machine, and the head is a project no test can reference.
/// What stays in the head is what only a phone has: a software keyboard to hide and show, a touch stream to
/// read a long press from, and the screen width that decides between a bottom panel and a side panel.
/// </para>
/// <para>
/// <b>Every key press, from the bar, the quick-access keyboard or the software keyboard itself, comes through
/// <see cref="Send"/>.</b> That is what makes the latches mean one thing — Ctrl pressed on the bar applies to
/// a letter typed on Gboard — and what lets the history see the whole of a line rather than the part typed on
/// one of the three.
/// </para>
/// <para>
/// <b>Customising saves as it goes.</b> There is no draft and no SAVE: every change on the customise screen is
/// a change to the bar that is already drawn under the sheet's scrim, and to the settings file, the moment it
/// is made. A keyboard is not a document, and "did that save" is not a question it should raise.
/// </para>
/// </remarks>
internal sealed partial class TerminalKeyboardViewModel : ObservableObject
{
private readonly ClientSettingsStore settings;
private readonly Func<TypingTarget?> target;
private readonly Func<uint, ReadOnlyMemory<byte>, ValueTask> send;
private readonly Func<uint, RecentOutput?> recent;
private readonly Func<uint, string, bool, CancellationToken, Task<bool>> insert;
private readonly Func<uint, Task> paste;
private readonly List<string> bar = [];
private readonly List<string> groupOrder = [];
private readonly HashSet<string> hiddenGroups = new(StringComparer.Ordinal);
private readonly List<CustomTerminalKey> customKeys = [];
/// <summary>One button model per key id, shared wherever the key is drawn, so a latch lights all of them.</summary>
private readonly Dictionary<string, KeyButtonViewModel> buttons = new(StringComparer.Ordinal);
private readonly TypedCommandHistory history = new();
/// <param name="settings">Where the arrangement is kept.</param>
/// <param name="target">The session keys go to right now, or null when no tab has one.</param>
/// <param name="send">Writes bytes to a session.</param>
/// <param name="recent">A session's recent output, for the history's echo test.</param>
/// <param name="insert">Types text into a session as the snippets screen does — the workspace's paste.</param>
/// <param name="paste">Pastes the clipboard into a session.</param>
internal TerminalKeyboardViewModel(
ClientSettingsStore settings,
Func<TypingTarget?> target,
Func<uint, ReadOnlyMemory<byte>, ValueTask> send,
Func<uint, RecentOutput?> recent,
Func<uint, string, bool, CancellationToken, Task<bool>> insert,
Func<uint, Task> paste)
{
this.settings = settings;
this.target = target;
this.send = send;
this.recent = recent;
this.insert = insert;
this.paste = paste;
var stored = settings.Read().TerminalKeyboard ?? new TerminalKeyboardSettings();
Load(stored);
selectedTab = Enum.TryParse<QuickAccessTab>(stored.QuickAccessTab, out var tab) ? tab : QuickAccessTab.Keys;
isSidePanelOpen = stored.SidePanelOpen;
cursorGestures = stored.CursorGestures;
Rebuild();
}
/// <summary>The bar, as rows of at most <see cref="TerminalKeyCatalog.BarRowWidth"/>.</summary>
internal ObservableCollection<KeyRowViewModel> BarRows { get; } = [];
/// <summary>The KEYS tab: every group that is switched on, in order.</summary>
internal ObservableCollection<KeyGroupViewModel> Groups { get; } = [];
/// <summary>The HISTORY tab.</summary>
internal ObservableCollection<HistoryEntry> History => history.Entries;
/// <summary>The customise screen's bar list.</summary>
internal ObservableCollection<BarEditorRowViewModel> BarEditor { get; } = [];
/// <summary>The customise screen's group list.</summary>
internal ObservableCollection<GroupEditorRowViewModel> GroupEditor { get; } = [];
/// <summary>The customise screen's list of keys somebody added.</summary>
internal ObservableCollection<CustomKeyRowViewModel> CustomKeyEditor { get; } = [];
/// <summary>Whether the bar has any keys on it; an empty bar is drawn as nothing at all.</summary>
internal bool HasBar => bar.Count > 0;
/// <summary>Whether the HISTORY tab has anything to show.</summary>
internal bool HasHistory => History.Count > 0;
/// <summary>Whether the bar can take another key.</summary>
internal bool CanAddToBar => bar.Count < TerminalKeyCatalog.MaximumBarKeys;
/// <summary>The customise screen's count line.</summary>
internal string BarCountText => $"{bar.Count} of {TerminalKeyCatalog.MaximumBarKeys}";
[ObservableProperty]
private bool isControlLatched;
[ObservableProperty]
private bool isAltLatched;
/// <summary>Whether the quick-access keyboard is up in place of the software keyboard.</summary>
[ObservableProperty]
private bool isQuickAccessOpen;
/// <summary>Whether a wide screen draws the quick-access keyboard beside the terminal.</summary>
[ObservableProperty]
private bool isSidePanelOpen;
/// <summary>Whether the customise screen is up.</summary>
[ObservableProperty]
private bool isCustomizing;
/// <summary>Whether long-press-and-drag on the terminal moves the cursor.</summary>
[ObservableProperty]
private bool cursorGestures;
[ObservableProperty]
private QuickAccessTab selectedTab;
[ObservableProperty]
private string newKeyLabel = string.Empty;
[ObservableProperty]
private string newKeySequence = string.Empty;
[ObservableProperty]
private string customizeStatus = string.Empty;
internal bool IsKeysTab => SelectedTab is QuickAccessTab.Keys;
internal bool IsSnippetsTab => SelectedTab is QuickAccessTab.Snippets;
internal bool IsHistoryTab => SelectedTab is QuickAccessTab.History;
internal bool IsAppearanceTab => SelectedTab is QuickAccessTab.Appearance;
// ── typing ───────────────────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Sends one key's worth of bytes to the selected session, through the latches and past the history.
/// </summary>
/// <remarks>
/// <para>
/// Both latches release after any key, including one they did not change. The person pressed Ctrl and
/// then a key; that was the key Ctrl was for, whatever it turned out to be.
/// </para>
/// <para>
/// Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has gone,
/// and awaiting would serialise every key behind a slow link.
/// </para>
/// </remarks>
internal void Send(byte[] bytes)
{
ArgumentNullException.ThrowIfNull(bytes);
if (bytes.Length == 0 || target() is not { } to)
{
return;
}
var payload = TerminalModifiers.Apply(bytes, IsControlLatched, IsAltLatched);
IsControlLatched = false;
IsAltLatched = false;
history.Observe(to.SessionId, to.Host, payload, () => recent(to.SessionId));
OnPropertyChanged(nameof(HasHistory));
_ = send(to.SessionId, payload).AsTask();
}
/// <summary>Sends the cursor gesture's arrows, which no latch applies to.</summary>
/// <remarks>
/// A gesture is not "the next key" in the sense a latch means — nobody pressing Ctrl on the bar and then
/// dragging the terminal means Ctrl+arrow by it, and spending the latch here would surprise the letter
/// typed afterwards. The history is still told, because the line has changed under it.
/// </remarks>
internal void SendArrow(ArrowDirection direction, int count)
{
if (count <= 0 || target() is not { } to)
{
return;
}
string? id = direction switch
{
ArrowDirection.Left => "left",
ArrowDirection.Down => "down",
ArrowDirection.Up => "up",
ArrowDirection.Right => "right",
_ => null,
};
if (id is null)
{
return;
}
var one = TerminalKeyCatalog.ById[id].Bytes;
var payload = new byte[one.Length * count];
for (var i = 0; i < count; i++)
{
one.CopyTo(payload, i * one.Length);
}
history.Observe(to.SessionId, to.Host, one, () => recent(to.SessionId));
_ = send(to.SessionId, payload).AsTask();
}
/// <summary>What a key does when pressed, wherever it is drawn.</summary>
internal void Press(TerminalKey key)
{
ArgumentNullException.ThrowIfNull(key);
switch (key.Kind)
{
case TerminalKeyKind.Control:
IsControlLatched = !IsControlLatched;
break;
case TerminalKeyKind.Alt:
IsAltLatched = !IsAltLatched;
break;
case TerminalKeyKind.Paste:
if (target() is { } to)
{
history.NoteInserted(to.SessionId, () => recent(to.SessionId));
_ = paste(to.SessionId);
}
break;
default:
Send(key.Bytes);
break;
}
}
/// <summary>Notes that a session's tab is gone, so a half-typed line is not kept for it.</summary>
internal void ForgetSession(uint sessionId) => history.Forget(sessionId);
/// <summary>Empties the history, which LOCK does as well as the tab's own CLEAR.</summary>
[RelayCommand]
internal void ClearHistory()
{
history.Clear();
OnPropertyChanged(nameof(HasHistory));
}
/// <summary>Types a history entry at the prompt, without pressing Enter.</summary>
/// <remarks>
/// The snippets screen's own route — typed rather than pasted for one line, and never with an Enter —
/// for the reason that screen gives: the remote may not be at a prompt any more, and running a command
/// out of a list is a thing to ask for rather than have happen.
/// </remarks>
[RelayCommand]
private async Task InsertHistoryAsync(HistoryEntry? entry)
{
if (entry is null || target() is not { } to)
{
return;
}
history.NoteInserted(to.SessionId, () => recent(to.SessionId));
await insert(to.SessionId, entry.Command, false, CancellationToken.None).ConfigureAwait(true);
}
// ── the panels ───────────────────────────────────────────────────────────────────────────────────────
/// <summary>Raises or lowers the quick-access keyboard.</summary>
[RelayCommand]
private void ToggleQuickAccess() => IsQuickAccessOpen = !IsQuickAccessOpen;
/// <summary>Lowers the quick-access keyboard, for the software keyboard coming back.</summary>
internal void CloseQuickAccess() => IsQuickAccessOpen = false;
/// <summary>Shows or hides the wide screen's side panel, and remembers which.</summary>
[RelayCommand]
private void ToggleSidePanel()
{
IsSidePanelOpen = !IsSidePanelOpen;
Save();
}
[RelayCommand]
private void SelectTab(QuickAccessTab tab)
{
SelectedTab = tab;
Save();
}
[RelayCommand]
private void ToggleCursorGestures()
{
CursorGestures = !CursorGestures;
Save();
}
partial void OnSelectedTabChanged(QuickAccessTab value)
{
OnPropertyChanged(nameof(IsKeysTab));
OnPropertyChanged(nameof(IsSnippetsTab));
OnPropertyChanged(nameof(IsHistoryTab));
OnPropertyChanged(nameof(IsAppearanceTab));
}
partial void OnIsControlLatchedChanged(bool value) => RefreshLatches();
partial void OnIsAltLatchedChanged(bool value) => RefreshLatches();
private void RefreshLatches()
{
foreach (var button in buttons.Values)
{
button.IsLatched = button.Key.Kind switch
{
TerminalKeyKind.Control => IsControlLatched,
TerminalKeyKind.Alt => IsAltLatched,
_ => false,
};
}
}
// ── customising ──────────────────────────────────────────────────────────────────────────────────────
[RelayCommand]
private void OpenCustomize()
{
CustomizeStatus = string.Empty;
IsQuickAccessOpen = false;
IsCustomizing = true;
}
[RelayCommand]
private void CloseCustomize()
{
NewKeyLabel = string.Empty;
NewKeySequence = string.Empty;
CustomizeStatus = string.Empty;
IsCustomizing = false;
}
/// <summary>Puts the bar and the groups back as shipped. Keys somebody added are kept, off the bar.</summary>
/// <remarks>
/// A custom key is a thing somebody wrote, and a reset that deleted it would be destroying work to tidy a
/// layout. It stays on the customise screen and in its own group, one tap from the bar again.
/// </remarks>
[RelayCommand]
private void ResetLayout()
{
bar.Clear();
bar.AddRange(TerminalKeyCatalog.DefaultBar);
groupOrder.Clear();
hiddenGroups.Clear();
NormaliseGroupOrder();
CustomizeStatus = "The bar and the groups are back as they shipped.";
Commit();
}
/// <summary>Adds the key the two boxes describe, to its own group and to the bar if there is room.</summary>
[RelayCommand]
private void AddCustomKey()
{
var label = NewKeyLabel.Trim();
if (label.Length is 0 or > 10)
{
CustomizeStatus = "A key needs a label of one to ten characters.";
return;
}
if (!KeySequence.TryParse(NewKeySequence, out var bytes) || bytes.Length == 0)
{
CustomizeStatus = NewKeySequence.Length == 0
? "Say what the key sends."
: "That sequence ends in a lone backslash or a \\x without two hex digits.";
return;
}
var id = TerminalKeyCatalog.CustomPrefix + Guid.CreateVersion7().ToString("N");
customKeys.Add(new CustomTerminalKey(id, label, NewKeySequence));
NormaliseGroupOrder();
var onBar = CanAddToBar;
if (onBar)
{
bar.Add(id);
}
CustomizeStatus = onBar
? $"Added '{label}' to the bar."
: $"Added '{label}' to YOUR KEYS. The bar is full; take a key off it to make room.";
NewKeyLabel = string.Empty;
NewKeySequence = string.Empty;
Commit();
}
private void ToggleOnBar(string id)
{
if (bar.Remove(id))
{
Commit();
return;
}
if (!CanAddToBar)
{
CustomizeStatus = $"The bar holds {TerminalKeyCatalog.MaximumBarKeys} keys. Take one off first.";
return;
}
bar.Add(id);
CustomizeStatus = string.Empty;
Commit();
}
private void MoveOnBar(string id, int by)
{
var from = bar.IndexOf(id);
var to = from + by;
if (from < 0 || to < 0 || to >= bar.Count)
{
return;
}
(bar[from], bar[to]) = (bar[to], bar[from]);
Commit();
}
private void MoveGroup(string id, int by)
{
var from = groupOrder.IndexOf(id);
var to = from + by;
if (from < 0 || to < 0 || to >= groupOrder.Count)
{
return;
}
(groupOrder[from], groupOrder[to]) = (groupOrder[to], groupOrder[from]);
Commit();
}
/// <remarks>
/// Saves and redraws the KEYS tab only. The switch that called this is a row of the customise screen's
/// own group list, and rebuilding that list would be pulling the control out from under the binding that
/// is still busy telling us about it.
/// </remarks>
private void SetGroupShown(string id, bool shown)
{
var changed = shown ? hiddenGroups.Remove(id) : hiddenGroups.Add(id);
if (changed)
{
Save();
RebuildGroups();
}
}
private void DeleteCustomKey(string id)
{
customKeys.RemoveAll(k => string.Equals(k.Id, id, StringComparison.Ordinal));
bar.Remove(id);
buttons.Remove(id);
NormaliseGroupOrder();
Commit();
}
// ── state ────────────────────────────────────────────────────────────────────────────────────────────
private void Load(TerminalKeyboardSettings stored)
{
foreach (var custom in stored.CustomKeys ?? [])
{
if (custom is { Id: { } id, Label: { } label, Sequence: { } sequence }
&& id.StartsWith(TerminalKeyCatalog.CustomPrefix, StringComparison.Ordinal)
&& TerminalKeyCatalog.FromCustom(id, label, sequence) is not null
&& customKeys.TrueForAll(k => !string.Equals(k.Id, id, StringComparison.Ordinal)))
{
customKeys.Add(custom);
}
}
foreach (var id in stored.Bar ?? [.. TerminalKeyCatalog.DefaultBar])
{
if (bar.Count < TerminalKeyCatalog.MaximumBarKeys && KeyFor(id) is not null && !bar.Contains(id))
{
bar.Add(id);
}
}
groupOrder.AddRange(stored.GroupOrder ?? []);
hiddenGroups.UnionWith(stored.HiddenGroups ?? []);
NormaliseGroupOrder();
}
/// <summary>
/// Every group this build has, exactly once: stored order first, anything new at the end.
/// </summary>
/// <remarks>
/// A group shipped after somebody arranged theirs lands at the bottom rather than not at all — they did
/// not hide it, so it is not hidden — and one this build does not know is dropped.
/// </remarks>
private void NormaliseGroupOrder()
{
var known = TerminalKeyCatalog.Groups.Select(g => g.Id).ToList();
if (customKeys.Count > 0)
{
known.Add(TerminalKeyCatalog.CustomGroupId);
}
var ordered = groupOrder
.Where(id => known.Contains(id, StringComparer.Ordinal))
.Distinct(StringComparer.Ordinal)
.ToList();
ordered.AddRange(known.Where(id => !ordered.Contains(id, StringComparer.Ordinal)));
groupOrder.Clear();
groupOrder.AddRange(ordered);
}
private TerminalKey? KeyFor(string id) =>
TerminalKeyCatalog.ById.TryGetValue(id, out var shipped) ? shipped
: customKeys.Find(k => string.Equals(k.Id, id, StringComparison.Ordinal)) is { } custom
? TerminalKeyCatalog.FromCustom(custom.Id, custom.Label, custom.Sequence)
: null;
private TerminalKeyGroup? GroupFor(string id)
{
if (string.Equals(id, TerminalKeyCatalog.CustomGroupId, StringComparison.Ordinal))
{
return new TerminalKeyGroup(
id,
"YOUR KEYS",
[.. customKeys.Select(k => KeyFor(k.Id)).OfType<TerminalKey>()]);
}
return TerminalKeyCatalog.Groups.FirstOrDefault(g => string.Equals(g.Id, id, StringComparison.Ordinal));
}
private KeyButtonViewModel ButtonFor(TerminalKey key)
{
if (!buttons.TryGetValue(key.Id, out var button))
{
button = new KeyButtonViewModel(key, Press);
buttons[key.Id] = button;
}
return button;
}
private void Commit()
{
Save();
Rebuild();
}
private void Save() =>
settings.Write(settings.Read() with
{
TerminalKeyboard = new TerminalKeyboardSettings(
Bar: [.. bar],
GroupOrder: [.. groupOrder],
HiddenGroups: [.. hiddenGroups.Order(StringComparer.Ordinal)],
CustomKeys: [.. customKeys],
QuickAccessTab: SelectedTab.ToString(),
SidePanelOpen: IsSidePanelOpen,
CursorGestures: CursorGestures),
});
/// <summary>Redraws the KEYS tab.</summary>
private void RebuildGroups()
{
Groups.Clear();
foreach (var id in groupOrder)
{
if (!hiddenGroups.Contains(id) && GroupFor(id) is { Keys.Count: > 0 } group)
{
Groups.Add(new KeyGroupViewModel(group.Title, [.. group.Keys.Select(ButtonFor)]));
}
}
}
/// <summary>Redraws everything that depends on the arrangement.</summary>
/// <remarks>
/// Wholesale rather than by diff. The lists are a few dozen rows, a change is a tap, and an incremental
/// update is a second account of the arrangement that can disagree with the first.
/// </remarks>
private void Rebuild()
{
var barKeys = bar.Select(KeyFor).OfType<TerminalKey>().ToList();
BarRows.Clear();
foreach (var row in barKeys.Chunk(TerminalKeyCatalog.BarRowWidth))
{
BarRows.Add(new KeyRowViewModel([.. row.Select(ButtonFor)]));
}
RebuildGroups();
GroupEditor.Clear();
for (var i = 0; i < groupOrder.Count; i++)
{
if (GroupFor(groupOrder[i]) is not { } group)
{
continue;
}
var id = group.Id;
GroupEditor.Add(new GroupEditorRowViewModel(
group.Title,
!hiddenGroups.Contains(id),
shown => SetGroupShown(id, shown),
i > 0 ? () => MoveGroup(id, -1) : null,
i < groupOrder.Count - 1 ? () => MoveGroup(id, 1) : null,
[.. group.Keys.Select(k => new KeyChoiceViewModel(k.Label, bar.Contains(k.Id), () => ToggleOnBar(k.Id)))]));
}
BarEditor.Clear();
for (var i = 0; i < barKeys.Count; i++)
{
var id = barKeys[i].Id;
BarEditor.Add(new BarEditorRowViewModel(
barKeys[i],
i < TerminalKeyCatalog.BarRowWidth ? "TOP ROW" : "SECOND ROW",
i > 0 ? () => MoveOnBar(id, -1) : null,
i < barKeys.Count - 1 ? () => MoveOnBar(id, 1) : null,
() => ToggleOnBar(id)));
}
CustomKeyEditor.Clear();
foreach (var custom in customKeys)
{
var id = custom.Id;
CustomKeyEditor.Add(new CustomKeyRowViewModel(custom.Label, custom.Sequence, () => DeleteCustomKey(id)));
}
RefreshLatches();
OnPropertyChanged(nameof(HasBar));
OnPropertyChanged(nameof(CanAddToBar));
OnPropertyChanged(nameof(BarCountText));
}
}
/// <summary>One key as drawn: on the bar, on the quick-access keyboard, or both at once.</summary>
internal sealed partial class KeyButtonViewModel : ObservableObject
{
internal KeyButtonViewModel(TerminalKey key, Action<TerminalKey> press)
{
Key = key;
PressCommand = new RelayCommand(() => press(key));
}
internal TerminalKey Key { get; }
internal string Label => Key.Label;
internal string Hint => Key.Hint;
internal bool IsWide => Key.IsWide;
internal IRelayCommand PressCommand { get; }
/// <summary>Whether this is a modifier that is on, which has to look on.</summary>
[ObservableProperty]
private bool isLatched;
}
/// <summary>One row of the bar; the markup divides its width evenly between the keys.</summary>
internal sealed record KeyRowViewModel(IReadOnlyList<KeyButtonViewModel> Keys);
/// <summary>One group on the KEYS tab.</summary>
internal sealed record KeyGroupViewModel(string Title, IReadOnlyList<KeyButtonViewModel> Keys);
/// <summary>One key on the customise screen's bar list.</summary>
internal sealed class BarEditorRowViewModel
{
internal BarEditorRowViewModel(TerminalKey key, string row, Action? moveUp, Action? moveDown, Action remove)
{
Label = key.Label;
Hint = key.Hint;
Row = row;
MoveUpCommand = new RelayCommand(() => moveUp?.Invoke(), () => moveUp is not null);
MoveDownCommand = new RelayCommand(() => moveDown?.Invoke(), () => moveDown is not null);
RemoveCommand = new RelayCommand(remove);
}
internal string Label { get; }
internal string Hint { get; }
/// <summary>Which row of the bar the key lands on, since the first eight are the top row.</summary>
internal string Row { get; }
internal IRelayCommand MoveUpCommand { get; }
internal IRelayCommand MoveDownCommand { get; }
internal IRelayCommand RemoveCommand { get; }
}
/// <summary>One group on the customise screen: its switch, its place, and which of its keys are on the bar.</summary>
internal sealed partial class GroupEditorRowViewModel : ObservableObject
{
private readonly Action<bool> setShown;
internal GroupEditorRowViewModel(
string title,
bool isShown,
Action<bool> setShown,
Action? moveUp,
Action? moveDown,
IReadOnlyList<KeyChoiceViewModel> keys)
{
Title = title;
this.isShown = isShown;
this.setShown = setShown;
Keys = keys;
MoveUpCommand = new RelayCommand(() => moveUp?.Invoke(), () => moveUp is not null);
MoveDownCommand = new RelayCommand(() => moveDown?.Invoke(), () => moveDown is not null);
}
internal string Title { get; }
internal IReadOnlyList<KeyChoiceViewModel> Keys { get; }
internal IRelayCommand MoveUpCommand { get; }
internal IRelayCommand MoveDownCommand { get; }
/// <summary>Whether the group is drawn on the KEYS tab.</summary>
[ObservableProperty]
private bool isShown;
partial void OnIsShownChanged(bool value) => setShown(value);
}
/// <summary>A key on the customise screen, lit when it is on the bar; tapping it puts it on or takes it off.</summary>
internal sealed class KeyChoiceViewModel
{
internal KeyChoiceViewModel(string label, bool isOnBar, Action toggle)
{
Label = label;
IsOnBar = isOnBar;
ToggleCommand = new RelayCommand(toggle);
}
internal string Label { get; }
internal bool IsOnBar { get; }
internal IRelayCommand ToggleCommand { get; }
}
/// <summary>A key somebody added, on the customise screen, with the way to delete it.</summary>
internal sealed class CustomKeyRowViewModel
{
internal CustomKeyRowViewModel(string label, string sequence, Action delete)
{
Label = label;
Sequence = sequence;
DeleteCommand = new RelayCommand(delete);
}
internal string Label { get; }
internal string Sequence { get; }
internal IRelayCommand DeleteCommand { get; }
}
@@ -867,14 +867,14 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
/// be a second decrypted copy of a secret for no gain.
/// </remarks>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
internal ReplaceableCollection<HostRowViewModel> Hosts { get; } = [];
[ObservableProperty]
private HostRowViewModel? selectedHost;
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
/// <inheritdoc cref="Hosts" path="/remarks" />
internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
internal ReplaceableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
[ObservableProperty]
private ObjectStoreRowViewModel? selectedBucket;
@@ -2289,6 +2289,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// ---- Moving files ----
/// <summary>What a transfer asked for while another command is still running is told.</summary>
private const string StillBusy =
"Still working on the last request. Nothing was queued — try again when it has finished.";
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
[RelayCommand]
private void Download()
@@ -2306,7 +2310,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true);
ClearRemoteChecks();
});
},
StillBusy);
}
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
@@ -2328,7 +2333,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true);
ClearLocalChecks();
});
},
StillBusy);
}
/// <summary>Unticks everything on the host's side.</summary>
@@ -2393,13 +2399,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
internal void QueueUploads(IReadOnlyList<string> paths) =>
_ = RunAsync(
"Working out what to upload…",
() => QueueUploadsAsync(paths, CancellationToken.None));
() => QueueUploadsAsync(paths, CancellationToken.None),
StillBusy);
/// <inheritdoc cref="QueueUploads" />
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows) =>
_ = RunAsync(
"Working out what to download…",
() => QueueDownloadsAsync(rows, CancellationToken.None));
() => QueueDownloadsAsync(rows, CancellationToken.None),
StillBusy);
internal async Task QueueUploadsAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
{
@@ -2580,7 +2588,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
_ = RunAsync(
"Working out what to upload…",
() => QueueUploadsAsync(paths, CancellationToken.None));
() => QueueUploadsAsync(paths, CancellationToken.None),
StillBusy);
}
/// <summary>
@@ -3977,7 +3986,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
}
}
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshHosts();
/// <remarks>
/// Only the picker the list feeds. Refilling both on either list's change made every reload refill the host
/// picker once for the hosts and again for the buckets.
/// </remarks>
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (vault is { } open && ReferenceEquals(sender, open.ObjectStores))
{
RefreshPickers(hosts: false, buckets: true);
}
else
{
RefreshPickers(hosts: true, buckets: false);
}
}
/// <summary>
/// Rebuilds both pickers from the vault's lists.
@@ -3995,34 +4018,34 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// connected to.
/// </para>
/// </remarks>
private void RefreshHosts()
private void RefreshHosts() => RefreshPickers(hosts: true, buckets: true);
private void RefreshPickers(bool hosts, bool buckets)
{
refreshingPickers = true;
try
{
Hosts.Clear();
if (vault is not { } open)
{
Hosts.ReplaceAll([]);
return;
}
foreach (var row in open.Hosts)
if (hosts)
{
Hosts.Add(row);
Hosts.ReplaceAll(open.Hosts);
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
}
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
Buckets.Clear();
foreach (var row in open.ObjectStores)
if (buckets)
{
Buckets.Add(row);
}
Buckets.ReplaceAll(open.ObjectStores);
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
SelectedBucket =
Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
}
}
finally
{
@@ -4154,11 +4177,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
/// pane permanently disabled.
/// <para>
/// <paramref name="refusedWhileBusy"/> is for the commands somebody can fire while another is running —
/// a drop, or the download and upload buttons pressed twice — which used to vanish without a word: the
/// files were not queued and nothing said so. A command given one says why it did nothing instead.
/// </para>
/// </remarks>
private async Task RunAsync(string busyMessage, Func<Task> work)
private async Task RunAsync(string busyMessage, Func<Task> work, string? refusedWhileBusy = null)
{
if (IsBusy)
{
if (refusedWhileBusy is not null)
{
Status = refusedWhileBusy;
}
return;
}
@@ -1663,7 +1663,7 @@ internal sealed partial class VaultViewModel(
/// host key list checks itself against, so a filter applied here would change what the application can
/// do rather than what it shows. <see cref="VisibleHosts"/> is the filtered view.
/// </remarks>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
internal ReplaceableCollection<HostRowViewModel> Hosts { get; } = [];
/// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary>
/// <remarks>
@@ -1677,7 +1677,7 @@ internal sealed partial class VaultViewModel(
/// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings.
/// </para>
/// </remarks>
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
internal ReplaceableCollection<HostRowViewModel> VisibleHosts { get; } = [];
/// <summary>Whether the grid has anything to draw.</summary>
/// <remarks>
@@ -1891,7 +1891,7 @@ internal sealed partial class VaultViewModel(
/// groupless keychain is one flat grid and nothing above it.
/// </para>
/// </remarks>
internal ObservableCollection<HostSectionViewModel> HostSections { get; } = [];
internal ReplaceableCollection<HostSectionViewModel> HostSections { get; } = [];
/// <summary>Every host on the board, in the order its cards are drawn — every section, one after another.</summary>
/// <remarks>
@@ -2003,7 +2003,7 @@ internal sealed partial class VaultViewModel(
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
/// <summary>The buckets to show, unpushed local state included.</summary>
internal ObservableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
internal ReplaceableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
/// <summary>The tags to show, with the number of hosts wearing each.</summary>
/// <remarks>
@@ -4714,18 +4714,14 @@ internal sealed partial class VaultViewModel(
var (rows, unreadable) = await FetchHostRowsAsync(cancellationToken).ConfigureAwait(true);
Hosts.Clear();
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
// hold a host with the same label and both are shown: which vault it is in is what tells them
// apart, which is why the row carries the name rather than the list deduplicating.
foreach (var host in rows
// apart, which is why the row carries the name rather than the list deduplicating. Swapped in whole,
// and announced once: see ReplaceableCollection for what a row at a time cost.
Hosts.ReplaceAll(rows
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
Hosts.Add(host);
}
.ThenBy(row => row.Label, StringComparer.CurrentCulture));
SelectedHost = SelectionAfterReload(selectedId);
ApplyTheChosenHosts();
@@ -4816,13 +4812,9 @@ internal sealed partial class VaultViewModel(
var selectedId = SelectedObjectStore?.EntityId;
ObjectStores.Clear();
foreach (var store in listing.Items
.OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture))
{
ObjectStores.Add(new ObjectStoreRowViewModel(store));
}
ObjectStores.ReplaceAll(listing.Items
.OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture)
.Select(store => new ObjectStoreRowViewModel(store)));
SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId);
@@ -5388,12 +5380,7 @@ internal sealed partial class VaultViewModel(
// a walk up the group tree, and this is the map that walk is made against.
var parents = EffectiveParents();
VisibleHosts.Clear();
foreach (var host in Hosts.Where(host => Matches(host, parents)))
{
VisibleHosts.Add(host);
}
VisibleHosts.ReplaceAll(Hosts.Where(host => Matches(host, parents)));
RebuildSidebarRows();
@@ -5403,7 +5390,7 @@ internal sealed partial class VaultViewModel(
// property directly, and a host that is not on screen is not one any of them should act on.
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the reset
// above is a null that has to be overwritten rather than read.
SelectedSidebarRow = SelectedHost;
@@ -5473,12 +5460,7 @@ internal sealed partial class VaultViewModel(
/// </remarks>
private void RebuildHostSections()
{
HostSections.Clear();
foreach (var built in BuildHostSections())
{
HostSections.Add(built);
}
HostSections.ReplaceAll(BuildHostSections());
// Computed rather than stored, and none of the four has a change notification of its own — the same
// arrangement RebuildVisibleHosts holds for HasVisibleHosts and NoVisibleHostsMessage, and for the
@@ -6367,7 +6349,7 @@ internal sealed partial class VaultViewModel(
{
// Before the sync, so a vault admitted here is one of the vaults that pass then pulls. The
// other order would show a newly shared vault as an empty one until the minute after.
await AdmitNewVaultsAsync(server.Account, cancellationToken).ConfigureAwait(true);
var vaultsChanged = await AdmitNewVaultsAsync(server.Account, cancellationToken).ConfigureAwait(true);
// Every vault this session can read, not only the one new items are filed into. A team's
// vault that never synced would show its hosts exactly once — at the unlock that first
@@ -6380,6 +6362,15 @@ internal sealed partial class VaultViewModel(
// that had just failed to sync, which is exactly the lie that flag exists to prevent.
LastSyncFailed = report.Any(vault => !vault.Succeeded);
if (!vaultsChanged && !MayHaveChangedTheKeychain(report))
{
// Nothing to redraw, so nothing is decrypted: see MayHaveChangedTheKeychain. The one thing a
// rebuild did that still has to happen is the ago-text on each card, which is a minute older.
RestringLastConnected();
return report;
}
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
@@ -6423,7 +6414,7 @@ internal sealed partial class VaultViewModel(
/// this list, and doing that on every quiet pass would rebuild a menu once a minute for nothing.
/// </para>
/// </remarks>
private async Task AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
private async Task<bool> AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
{
var before = session.Vaults.Count;
@@ -6431,12 +6422,41 @@ internal sealed partial class VaultViewModel(
if (admitted == 0 && session.Vaults.Count == before)
{
return;
return false;
}
VaultsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
/// <summary>Whether a pass could have left the keychain different from the lists on screen.</summary>
/// <remarks>
/// <para>
/// Every pass used to end by reading the whole keychain back — decrypting every host, key, password,
/// bucket and snippet and rebuilding every list from them — once a minute, whether or not a single item
/// had moved. Almost every pass moves nothing, so almost all of that was spent redrawing what was already
/// drawn.
/// </para>
/// <para>
/// ◆ Conservative on purpose. A failed vault counts as changed, because a pull can apply a page and then
/// fail on the next one, and a report with no counts is no evidence that nothing landed. Log entries do
/// not count — a machine reads back the ones it just wrote, and nothing on these lists is drawn from the
/// log; see <see cref="SyncReport.PulledItems"/>.
/// </para>
/// </remarks>
private static bool MayHaveChangedTheKeychain(IReadOnlyList<VaultSyncReport> report) =>
report.Any(vault => vault.Report is not { } pass
|| pass.PulledItems > 0
|| pass.PushedItems > 0
|| pass.Merged > 0
|| pass.Resurrected > 0
|| pass.Recreated > 0
|| pass.Pruned > 0
|| pass.DeletesAbandoned > 0
|| pass.Parked > 0
|| pass.ResyncedFromStart);
/// <summary>
/// Raised when a synchronisation pass found that the vaults this account can reach have changed.
/// </summary>
@@ -13141,13 +13161,27 @@ internal sealed partial class VaultViewModel(
+ "the beginning. "
: string.Empty;
// Not a matter for attention — the server had already deleted these, and the machine is only catching
// up — but said, because items going from the list unannounced looks like losing them.
var pruned = report.Pruned > 0
? $" {report.Pruned} item(s) deleted elsewhere while this machine was away were removed."
: string.Empty;
if (!report.NeedsAttention)
{
return replayed + (report.Pulled == 0 && report.Pushed == 0
return replayed + (report.Pulled == 0 && report.Pushed == 0 && report.Pruned == 0
? "Already up to date."
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.");
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.") + pruned;
}
var notes = AttentionNotes(report);
return replayed + "Synchronised, but: " + string.Join("; ", notes) + "." + pruned;
}
/// <summary>What a pass that needs attention says, one clause per thing.</summary>
private static List<string> AttentionNotes(SyncReport report)
{
var notes = new List<string>();
// "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind
@@ -13157,6 +13191,11 @@ internal sealed partial class VaultViewModel(
notes.Add($"{report.Resurrected} item(s) deleted elsewhere were kept under a new name");
}
if (report.Recreated > 0)
{
notes.Add($"{report.Recreated} item(s) deleted elsewhere long ago were put back, with this machine's changes");
}
if (report.DeletesAbandoned > 0)
{
notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit");
@@ -13182,7 +13221,7 @@ internal sealed partial class VaultViewModel(
+ "before you can see anything written since");
}
return replayed + "Synchronised, but: " + string.Join("; ", notes) + ".";
return notes;
}
private async Task RunAsync(string busyMessage, Func<Task> work)
@@ -468,7 +468,21 @@ internal sealed class SshNetConnection(
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
/// <inheritdoc />
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
/// <remarks>
/// <para>
/// On the thread pool, because <c>CreateShellStream</c> is synchronous and is three round trips to the
/// server — open a channel, ask for a terminal, ask for a shell — and this used to make them on whichever
/// thread called it, which is the UI thread. On a distant or overloaded server the window stopped
/// painting for as long as they took, and the cancellation token it was given was read once, before any
/// of them.
/// </para>
/// <para>
/// SSH.NET's call cannot itself be cancelled, so a cancellation stops the wait rather than the call. A
/// shell that arrives after nobody is waiting for it is closed as it lands rather than left holding a
/// channel on the server.
/// </para>
/// </remarks>
public async Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -476,15 +490,53 @@ internal sealed class SshNetConnection(
// 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here
// only delays the first byte reaching the screen.
var shell = client.CreateShellStream(
"xterm-256color",
effective.Columns,
effective.Rows,
effective.PixelWidth,
effective.PixelHeight,
4096);
var opening = Task.Run(
() => client.CreateShellStream(
"xterm-256color",
effective.Columns,
effective.Rows,
effective.PixelWidth,
effective.PixelHeight,
4096),
CancellationToken.None);
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client));
try
{
var shell = await opening.WaitAsync(cancellationToken).ConfigureAwait(false);
return new SshNetShellSession(shell, client);
}
catch (OperationCanceledException)
{
_ = CloseWhenItLandsAsync(opening);
throw;
}
}
/// <summary>Closes a shell nobody is waiting for any more, or lets its failure go unremarked.</summary>
/// <remarks>
/// A shell that failed to open after its caller gave up has nobody to tell, and the connection it was
/// on reports its own death through the session that owns it. The failures are the ones SSH.NET raises
/// for a channel or a connection that went away, named so that anything else still surfaces.
/// </remarks>
private static async Task CloseWhenItLandsAsync(Task<ShellStream> opening)
{
ShellStream shell;
try
{
shell = await opening.ConfigureAwait(false);
}
catch (Exception exception) when (exception is SshException
or System.Net.Sockets.SocketException
or ObjectDisposedException
or InvalidOperationException)
{
return;
}
await shell.DisposeAsync().ConfigureAwait(false);
}
/// <inheritdoc />
+23
View File
@@ -240,6 +240,15 @@ internal sealed class CachedItemRow
public bool IsDeleted { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
/// <summary>The last full pull that saw this item on the server; null if none has since this was kept.</summary>
/// <remarks>
/// The server collects tombstones after their retention (ADR 0003), so a machine away longer than that
/// is never told of some deletions: it is refused, starts over, and pulls everything that still exists.
/// What the pull from nothing did not mark is what the server no longer has. See
/// <c>ItemStore.SweepAsync</c>.
/// </remarks>
public long? SweepMark { get; set; }
}
/// <summary>
@@ -361,6 +370,20 @@ internal sealed class SyncStateRow
/// beside the cursor so a change of list starts the pull over; see <c>SyncEngine.PullAsync</c>.
/// </remarks>
public string? PulledTypes { get; set; }
/// <summary>The mark of the pull from nothing in progress, or null when none is.</summary>
/// <remarks>
/// Kept here rather than in memory because a pull from nothing can take several passes — the pages per
/// pass are capped — and what it has seen has to survive between them. See <see cref="CachedItemRow.SweepMark"/>.
/// </remarks>
public long? SweepMark { get; set; }
/// <summary>The highest change sequence that pull has read so far.</summary>
/// <remarks>
/// The sweep's upper bound: an item written here later than this — one this machine pushed while the
/// pull was under way — is newer than anything the pull has read, and not evidence of anything.
/// </remarks>
public long SweepSeenUpTo { get; set; }
}
/// <summary>Something the merge had to override, or an item that could not be processed.</summary>
+147
View File
@@ -118,6 +118,153 @@ public sealed class ItemStore(
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Writes the server's version of many items at once, in one transaction, in the order given.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="SaveAsync"/> for a page of pulled changes. A pull used to write each change on its own —
/// a context, a read, a commit — so a first sync of a keychain with a few thousand items was a few
/// thousand SQLite transactions, each one a sync to disk. This is one read for the rows that exist
/// already and one commit for the lot.
/// </para>
/// <para>
/// The same blind overwrite, and the same answer when one item appears more than once: they are applied
/// in order to the same row, so the last one given is the one kept — exactly what writing them one by one
/// would have left.
/// </para>
/// </remarks>
public async Task SaveManyAsync(IReadOnlyCollection<StoredItem> items, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(items);
if (items.Count == 0)
{
return;
}
var vaultIds = items.Select(item => item.VaultId).Distinct().ToList();
var entityIds = items.Select(item => item.EntityId).Distinct().ToList();
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var existing = await context.Set<CachedItemRow>()
.Where(r => vaultIds.Contains(r.VaultId) && entityIds.Contains(r.EntityId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var rows = existing.ToDictionary(row => (row.VaultId, row.EntityType, row.EntityId));
foreach (var item in items)
{
if (!rows.TryGetValue((item.VaultId, item.EntityType, item.EntityId), out var row))
{
row = new CachedItemRow
{
VaultId = item.VaultId,
EntityType = item.EntityType,
EntityId = item.EntityId,
};
context.Add(row);
rows.Add((item.VaultId, item.EntityType, item.EntityId), row);
}
Apply(row, item);
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Marks the items a pull from nothing has just read as ones the server still has.</summary>
/// <remarks>One update per type in the page; see <see cref="SweepAsync"/>.</remarks>
public async Task MarkSeenAsync(
Guid vaultId,
long mark,
IReadOnlyCollection<(SyncEntityType Type, Guid Id)> seen,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(seen);
if (seen.Count == 0)
{
return;
}
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
foreach (var group in seen.GroupBy(item => item.Type))
{
var type = group.Key;
var ids = group.Select(item => item.Id).Distinct().ToList();
await context.Set<CachedItemRow>()
.Where(row => row.VaultId == vaultId && row.EntityType == type && ids.Contains(row.EntityId))
.ExecuteUpdateAsync(set => set.SetProperty(row => row.SweepMark, mark), cancellationToken)
.ConfigureAwait(false);
}
}
/// <summary>The highest change sequence held for a vault, or zero for none.</summary>
/// <remarks>Where a pull from nothing starts its sweep's upper bound; see <see cref="SweepAsync"/>.</remarks>
public async Task<long> HighestSequenceAsync(Guid vaultId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set<CachedItemRow>()
.Where(row => row.VaultId == vaultId)
.MaxAsync(row => (long?)row.ChangeSequence, cancellationToken)
.ConfigureAwait(false) ?? 0;
}
/// <summary>
/// Removes the items a completed pull from nothing did not see, which are the ones the server no longer has.
/// </summary>
/// <remarks>
/// <para>
/// The other half of the server's tombstone collection. A machine that missed a deletion for longer than
/// the retention is refused its cursor and pulls everything again — and everything again does not include
/// the item, so its copy here would otherwise last for ever, and its next edit would push it back.
/// </para>
/// <para>
/// ◆ Two kinds of row are not evidence and are kept. One newer than <paramref name="seenUpTo"/> was
/// written by this machine's own push during the pull: the bound is the higher of what the cache held
/// when the pull began and what it has read since, and the server's head is past both, so a push lands
/// above it. One with a change still waiting to go is the user's work: it is
/// left for the push, which finds the server without the item and says so — see
/// <c>SyncEngine.RetryAsCreateAsync</c> — rather than being thrown away here unseen.
/// </para>
/// </remarks>
/// <returns>How many items went.</returns>
public async Task<int> SweepAsync(
Guid vaultId,
IReadOnlyCollection<SyncEntityType> types,
long mark,
long seenUpTo,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(types);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
// Only the types the pull asked for: a row of any other type was never going to be marked.
return await context.Set<CachedItemRow>()
.Where(row => row.VaultId == vaultId
&& types.Contains(row.EntityType)
&& (row.SweepMark == null || row.SweepMark != mark)
&& row.ChangeSequence <= seenUpTo
&& !context.Set<OutboxRow>().Any(pending =>
pending.VaultId == row.VaultId
&& pending.EntityType == row.EntityType
&& pending.EntityId == row.EntityId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Removes a tombstone whose change has been seen by everything that needed it.</summary>
/// <remarks>
/// Only ever called for a row that is already a tombstone. Collecting a live item here would make
@@ -0,0 +1,481 @@
// <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("20261002152108_AddSweepMarks")]
partial class AddSweepMarks
{
/// <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.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,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
/// <inheritdoc />
public partial class AddSweepMarks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "sweep_mark",
table: "sync_state",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "sweep_seen_up_to",
table: "sync_state",
type: "INTEGER",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<long>(
name: "sweep_mark",
table: "item",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "sweep_mark",
table: "sync_state");
migrationBuilder.DropColumn(
name: "sweep_seen_up_to",
table: "sync_state");
migrationBuilder.DropColumn(
name: "sweep_mark",
table: "item");
}
}
}
@@ -59,6 +59,10 @@ namespace DodoSSH.Client.Storage.Migrations
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long?>("SweepMark")
.HasColumnType("INTEGER")
.HasColumnName("sweep_mark");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
@@ -370,6 +374,14 @@ namespace DodoSSH.Client.Storage.Migrations
.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");
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more