Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad1a129159 | ||
|
|
e240c0cf26 | ||
|
|
96dd176121 | ||
|
|
367cebae9b | ||
|
|
c21de4ade4 | ||
|
|
858e36279d | ||
|
|
55e5de0ce4 | ||
|
|
8cb1457ca9 |
No files matched your search
@@ -10,7 +10,7 @@
|
||||
"rollForward": false
|
||||
},
|
||||
"vpk": {
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.161",
|
||||
"commands": [
|
||||
"vpk"
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# Android design audit — 2026-10-02
|
||||
|
||||
Run on the emulator (`dodossh_api36`, Pixel 5 profile, 1080×2340 @ 440 dpi, Android 16). It used a
|
||||
standalone **Debug** APK from `main` at `858e362`, against a local API, Keycloak and a throwaway `sshd`.
|
||||
The walk went from a cleared install through sign-in, enrolment, recovery code, unlock, adding a host,
|
||||
connecting, host-key trust, terminal, and every Settings sub-page.
|
||||
|
||||
Screenshots are in [`android/`](android/), downscaled. The recovery-code screen can't be captured,
|
||||
because `FLAG_SECURE` blocks it, so it was reviewed from its markup and the accessibility tree.
|
||||
|
||||
**How to read the evidence.** "a11y tree" means `uiautomator dump`, which reads what TalkBack and any
|
||||
other accessibility service see. Times and hangs come from a Debug build on an emulator (JIT, no AOT),
|
||||
so treat any performance note as "confirm on Release".
|
||||
|
||||
---
|
||||
|
||||
## What works well
|
||||
|
||||
- **Connection progress card** ([11](android/11-connecting.png)): five named steps, the current one lit,
|
||||
with *close* and *logs* available while it's in progress.
|
||||
- **Host-key sheet** ([13](android/13-host-key.png)): "UNKNOWN HOST KEY", first contact, the algorithm,
|
||||
the full SHA256 fingerprint, and an instruction to compare. **TRUST AND CONNECT** and **CANCEL** are
|
||||
both full width.
|
||||
- **Recovery-code screen**:
|
||||
- no back and no skip;
|
||||
- **CONTINUE** stays disabled until the tick;
|
||||
- a full-width **COPY CODE**;
|
||||
- capture is blocked.
|
||||
- **Terminal** ([14](android/14-terminal.png)):
|
||||
- resizes above the keyboard;
|
||||
- the two-row accessory bar (esc, tab, ctrl, `| - ~ /`, arrows, A−/A+) stays reachable;
|
||||
- the open-sessions strip and the tab strip are clear.
|
||||
- **Busy states**: enrolment disables its fields and button and explains the wait ("deliberately takes a
|
||||
moment").
|
||||
- **Touch targets** measure 44 dp or more almost everywhere: checkboxes, buttons, rows.
|
||||
- **Back** closes sheets. The soft keyboard's ✓ submits single-field forms (unlock, connect).
|
||||
- **Copy is honest and specific**, e.g. "works with no network — the keychain decrypts on this phone",
|
||||
and the Vaults explanation.
|
||||
|
||||
---
|
||||
|
||||
## Critical / high
|
||||
|
||||
### H1 · Password fields expose their plaintext to accessibility services
|
||||
|
||||
- **What:** the vault passphrase field ([05](android/05-unlock-prefilled.png)) and the SSH password field
|
||||
([10](android/10-connect-sheet.png)) both report `password="false"` in the a11y tree. Their `text`
|
||||
attribute is the secret itself, in clear.
|
||||
- **Evidence:** the a11y tree on both screens; the values matched exactly what was typed (redacted here).
|
||||
- **Why it matters:** any installed app holding an accessibility service can read every passphrase and
|
||||
host password typed into DodoSSH. That's a common Android malware technique. TalkBack would also read
|
||||
the passphrase aloud character by character.
|
||||
- **Where:** `TextBox.secret` sets `PasswordChar` and `ContentType=Password`
|
||||
(`Theme/Phone.axaml:583`). That's correct for the input method, but Avalonia's Android automation peer
|
||||
ignores it.
|
||||
- **Fix:** give secret boxes an automation peer that reports `IsPassword` and returns no value. At
|
||||
minimum, override the exposed text on `TextBox.secret`. File it upstream with Avalonia as well. Check
|
||||
the desktop's UIA peer for the same leak.
|
||||
|
||||
### H2 · The passphrase survives enrolment and pre-fills the unlock box
|
||||
|
||||
- **What:** right after the recovery code, the unlock screen already holds the passphrase (16 dots;
|
||||
[05](android/05-unlock-prefilled.png)) beside "Unlock with the passphrase you just chose."
|
||||
- **Why:** `ConfirmRecoveryCode` clears `RecoveryCode` from memory on purpose ("keeping it in a view
|
||||
model … would undo that"), but `Passphrase` is never cleared after enrolment. Combined with H1, it's
|
||||
readable from the a11y tree on that screen. It also skips the one moment that proves the user
|
||||
remembers the passphrase.
|
||||
- **Fix:** clear `Passphrase` and its confirmation when enrolment completes, as the recovery code is
|
||||
cleared.
|
||||
|
||||
### H3 · A hardware Enter that submits the connect sheet leaves the window "not responding"
|
||||
|
||||
- **What:** I typed the password and pressed Enter (an injected `KEYCODE_ENTER`, which is what a
|
||||
Bluetooth or DeX keyboard sends). The connection started, but Android reported the window
|
||||
unresponsive. The key-up was never acknowledged (`InputDispatcher: Waited 5009ms for
|
||||
KeyEvent … ENTER … action=UP`). The ANR dialog then kept coming back
|
||||
([12](android/12-anr.png)) until the process was killed. Meanwhile the main thread was idle in
|
||||
`Looper.pollOnce`: it wasn't busy, the event was simply lost.
|
||||
- **Not affected:** tapping **CONNECT**, and the soft keyboard's ✓ (which sends an editor action, not a
|
||||
key event). Both connected cleanly.
|
||||
- **Likely cause:** focus moves to the terminal's native renderer (`Platform/RendererFocus.cs`) while
|
||||
the Enter key-up is still in flight.
|
||||
- **Repro:** host row → type password → hardware Enter.
|
||||
- **Confidence:** reproduced once, and an accessibility dump was running at the time. Confirm on a real
|
||||
device with a keyboard.
|
||||
|
||||
### H4 · Screen-reader names are type names across the app
|
||||
|
||||
The a11y tree announces these:
|
||||
|
||||
| Control | Announced as |
|
||||
|---|---|
|
||||
| Bottom nav (Hosts / Connections / Settings) | `Avalonia.Controls.Panel` ×3 |
|
||||
| Add-sheet rows, Settings rows, session chip | `Avalonia.Controls.StackPanel` / `Avalonia.Controls.Grid` |
|
||||
| Keychain filter chips | `Avalonia.Controls.StackPanel` ×5 |
|
||||
| Host list rows | `DodoSSH.Client.Shell.ViewModels.HostRowViewModel` |
|
||||
| Host editor dropdowns | `AuthenticationChoice { Kind = Typed, EntityId = , … }`, `GroupChoice { … }` |
|
||||
| Vault row | `VaultRowViewModel { VaultId = 01a0fe77-… }` (leaks an internal id) |
|
||||
| FAB / header add | `+` (no name) |
|
||||
| Decorative glyphs | `⌕ ▤ ⌗ ⚙ ⚿ ❯ ⇅ ◳ ≡ ◎ ›` exposed as text |
|
||||
|
||||
There are two further problems:
|
||||
- **Hidden content:** the hosts list stays in the tree under the full-screen host editor, so a
|
||||
screen-reader user can wander into a screen they can't see.
|
||||
- **Leftover desktop controls:** the desktop scrollbar's "Line up / Page down" buttons are exposed too.
|
||||
|
||||
**Fix:** set `AutomationProperties.Name` on every button whose content isn't a string. Give row
|
||||
templates a name, or override `ToString` on the row view models. Mark glyph `TextBlock`s
|
||||
`AutomationProperties.AccessibilityView="Raw"`. Hide covered content from the tree while an editor
|
||||
covers it.
|
||||
|
||||
### H5 · Copying the recovery code isn't marked sensitive
|
||||
|
||||
`CopyRecoveryCode` goes through Avalonia's plain `SetTextAsync` (`App.axaml.cs:187`). On Android 13+,
|
||||
the system shows a clipboard preview of the copied text. That overlay is system UI, so this screen's
|
||||
`FLAG_SECURE` doesn't cover it, and Gboard keeps the text in its clipboard history. Use a native
|
||||
`ClipData` with `ClipDescription.EXTRA_IS_SENSITIVE` for any secret the app copies. (Read from code;
|
||||
not observed, because the screen can't be captured.)
|
||||
|
||||
---
|
||||
|
||||
## Medium — visual system
|
||||
|
||||
### M1 · The accent glow is clipped to a square on every primary button and the FAB
|
||||
|
||||
Each gradient button sits on a slightly lighter rectangle, and its corners show past the rounded shape:
|
||||
- [z-glow-button](android/z-glow-button.png): SIGN IN, CREATE THE VAULT, UNLOCK, CONNECT, SAVE, ADD
|
||||
FILES;
|
||||
- [z-glow-fab](android/z-glow-fab.png): the round FAB sits inside a visible square.
|
||||
|
||||
`AccentGlow` (`0 0 22 0`) is set on the template's `ContentPresenter`
|
||||
(`Phone.axaml` `Button.primary` / `Button.fab /template/ ContentPresenter`) and gets clipped to the
|
||||
button's own bounds. The desktop's `Button.accent` uses the same pattern, so check it there too. Fix:
|
||||
draw the glow from an unclipped wrapper (a `Border` with margin), or drop it on the phone.
|
||||
|
||||
### M2 · Body copy is monospace, against the shared-typeface decision
|
||||
|
||||
`docs/design-import-gaps.md` ("The phone catches up") records that the phone shares the desktop's
|
||||
faces. On the desktop, prose (`hint`, `heading`, the settings titles and captions) is Montserrat, and
|
||||
mono is kept for identifiers (`.mono`) and labels (`.label`). On the phone, `TextBlock.body`, `.title`
|
||||
and `.detail` all still set `MonoFont` (`Phone.axaml:661–680`). So every paragraph, title and caption is
|
||||
JetBrains Mono, and only screen headers (Personal, Settings, New host) are Montserrat. The result is
|
||||
long, wide-set paragraphs that are tiring to read ([03](android/03-enrol.png),
|
||||
[16](android/16-settings.png), [19](android/19-sftp.png)).
|
||||
|
||||
Keep mono for labels, buttons (the recorded ALL-CAPS-mono convention), addresses and code. Move
|
||||
`.body`, `.title` and `.detail` to the default sans.
|
||||
|
||||
### M3 · Fluent's selection fill leaks into the host list
|
||||
|
||||
After a connect, the tapped row stays lit in `#3d486b` ([15](android/15-selected-row.png)). That
|
||||
slate-blue isn't in `Palette.axaml`, which says selection is `Active` `#1E1E2C`. The comment at
|
||||
`HostsScreen.axaml:360` says the selection fill was deliberately removed ("a lit row would be a mark
|
||||
with no action behind it"). But only the custom `Border.hostrow.chosen` style was changed. Fluent's
|
||||
`ListBoxItem:selected /template/ ContentPresenter` background was never overridden.
|
||||
|
||||
### M4 · Disabled secondary buttons are heavier than enabled ones
|
||||
|
||||
`Button.secondary` has no `:disabled` style, so Fluent's `#3c3c43` slab shows through. SFTP's disabled
|
||||
**SAVE FILE** is the most prominent button in its row ([z-sftp-actions](android/z-sftp-actions.png)).
|
||||
Mirror `Button.primary:disabled`: a `Raised` fill and `TextFaint` text.
|
||||
|
||||
### M5 · Unstyled Fluent controls among styled ones
|
||||
|
||||
- **Dropdowns:** `ComboBox` keeps Fluent's ~4 px radius, bright white border and black fill beside
|
||||
10 px, softly bordered fields (host editor, SFTP host picker; [08](android/08-host-editor.png)).
|
||||
- **Checkboxes:** the default `CheckBox`, with its label sitting below the box's centre (relay, remember
|
||||
password, press-Enter; [10](android/10-connect-sheet.png)).
|
||||
- **Focused fields:** the fill switches from `Field` to pure `#000`.
|
||||
- **Scrollbars:** desktop scrollbars are always visible. On Keychain, the horizontal bar is drawn over
|
||||
the bottom of the filter chips ([17](android/17-keychain.png)). Android expects auto-hiding overlay
|
||||
bars.
|
||||
- **Stuck hover:** after a tap, Vaults' ↻ keeps a grey hover square. Touch has no hover, so
|
||||
`:pointerover` styles need a touch-safe variant.
|
||||
|
||||
### M6 · The terminal's colour and font have drifted from the palette
|
||||
|
||||
`WebAssets/terminal.css` says its `#171a26` background "is `TerminalSurface` in Palette.axaml". The
|
||||
palette now says `#18161F`. The terminal therefore reads navy, against the app's purple-black
|
||||
([14](android/14-terminal.png)). Its font stack starts at `ui-monospace` and never names the embedded
|
||||
JetBrains Mono, so on Android the WebView falls back to the system mono. That makes two different
|
||||
monospace faces on one screen.
|
||||
|
||||
### M7 · Iconography
|
||||
|
||||
- **Glyphs, not icons:** the phone uses Unicode glyphs (`⚿ ❯ ⇅ ◳ ≡ ◎ ⚙ ▤ ⌗ ⌕`), even though Material
|
||||
Icons is embedded and the desktop uses it. Weights and optical sizes vary (the heavy purple `❯` beside
|
||||
hairline `◳`; [16](android/16-settings.png)).
|
||||
- **Reused meanings:** ⚙ is both the Settings tab and the Preferences row, and ⚿ is both Keychain and
|
||||
"Lock the keychain".
|
||||
- **Splash:** the splash shows a flat purple circle, while the app's own icon is a rounded square with
|
||||
the gradient.
|
||||
|
||||
---
|
||||
|
||||
## Medium — information architecture and flows
|
||||
|
||||
### M8 · "Settings" holds the product
|
||||
|
||||
Keychain, Snippets, SFTP, S3, Logs and Vaults all sit under the ⚙ **Settings** tab, beside
|
||||
Preferences and Lock ([16](android/16-settings.png)). File transfer and snippets are working tools, not
|
||||
settings. Options:
|
||||
- rename the tab ("More" or "Tools") and keep Preferences as one row in it;
|
||||
- or promote SFTP to the bottom nav. Its icon is already ⇅.
|
||||
|
||||
### M9 · Keychain can't add anything
|
||||
|
||||
The empty state says "Add an SSH key or a password to stop typing one" ([17](android/17-keychain.png)).
|
||||
The screen has no add control: no FAB, no header +, nothing in the a11y tree. Passwords can only be
|
||||
created from the host editor's "+ NEW CREDENTIAL", and no phone path adds an SSH key. If keys are
|
||||
desktop-only, the empty state should say so.
|
||||
|
||||
### M10 · Three ways to add, two ways to edit
|
||||
|
||||
- **Add:** Hosts has a FAB, Snippets a header "+", Keychain nothing, and the terminal's header "+"
|
||||
means "new session".
|
||||
- **Edit:** the host editor is a full-screen page with ← and ✓ ([08](android/08-host-editor.png)). The
|
||||
snippet editor is an inline card with **SAVE/CANCEL**, and the header "+" stays live while it's open
|
||||
([18](android/18-snippet-editor.png)).
|
||||
|
||||
Pick one pattern for each.
|
||||
|
||||
### M11 · SFTP
|
||||
|
||||
- **Action block:** it covers more than half the screen (seven buttons, a permanent help paragraph and a
|
||||
status line), so only about four file rows are visible ([19](android/19-sftp.png)).
|
||||
- **No selection:** RENAME, MOVE, PERMS and DELETE are enabled with nothing selected. Tapping one only
|
||||
writes "Choose something on the host to rename." into the status line at the bottom.
|
||||
- **DELETE:** neutral-styled here, while delete is danger-red on hosts and the keychain.
|
||||
- **Directory rows:** marked only by a tiny purple ▸, with no folder icon.
|
||||
- **Password again:** it's asked for again although a shell to the same host is already open.
|
||||
- **S3:** blocked while an SFTP session is open. Tapping S3 bounces to SFTP, with "An SFTP session is
|
||||
open. Close it before opening a bucket." at the bottom ([20](android/20-s3-bounced.png)).
|
||||
|
||||
Suggestion: a contextual action bar that appears with a selection, help moved behind an ⓘ, and the
|
||||
listing given the screen.
|
||||
|
||||
### M12 · Bottom sheets
|
||||
|
||||
- **Partial scrim:** the scrim covers only the content area. The header (with **LOCK**) and the bottom
|
||||
nav stay bright and tappable under every sheet ([07](android/07-add-sheet.png),
|
||||
[10](android/10-connect-sheet.png)).
|
||||
- **Decorative handle:** it has a drag handle, but swipe-down doesn't dismiss.
|
||||
- **Button order:** the primary action is on the left (CONNECT | CANCEL, SAVE | CANCEL). Android puts
|
||||
the confirming action on the right.
|
||||
- **No autofocus:** the connect sheet's password field isn't focused on open, so it takes an extra tap.
|
||||
- **Nav above keyboard:** the bottom nav rides up above the keyboard and eats the sheet's space
|
||||
([13 before trust](android/13-host-key.png)).
|
||||
|
||||
### M13 · Sign-in and enrolment
|
||||
|
||||
- **Full browser:** sign-in opens a full Chrome tab (`ChromeTabbedActivity`), not a Custom Tab, so the
|
||||
user leaves the app. Chrome's own first-run prompts then land on top.
|
||||
- **Unannounced second sign-in:** step 2 asks for a passphrase, and **CREATE THE VAULT** then sends the
|
||||
user to the identity provider's login form a second time. That's the deliberate `prompt=login` key
|
||||
binding (`OidcClient.AuthorizeKeyBindingAsync`), but no copy warns that it's coming. Say "you'll
|
||||
confirm your identity once more" on the passphrase screen.
|
||||
- **Blank resume:** after the browser returns, the app showed a blank `#05050a` canvas for 5–10 s with
|
||||
no progress indicator. Every shell state has a screen, so this looks like surface re-creation on
|
||||
resume (Debug + emulator). Confirm on Release, and keep a visible state on screen while it happens.
|
||||
- **Step labels:** start at "STEP 2 OF 3"; step 1 is never labelled.
|
||||
|
||||
### M14 · The global status line leaks across screens
|
||||
|
||||
A single `StatusMessage` follows the user around:
|
||||
- "Unlocked 'Personal'." in the middle of Preferences ([21](android/21-preferences.png));
|
||||
- "Synchronised: 2 in, 2 out." after adding one host. `Describe` counts `Pulled`/`Pushed`, which include
|
||||
the app's own log entries, while `PulledItems`/`PushedItems` already exist for this;
|
||||
- "audit-box asks for a password." repeated under the sheet that asks for it.
|
||||
|
||||
Scope messages to the screen that raised them, or use transient snackbars.
|
||||
|
||||
---
|
||||
|
||||
## Low — input, copy, polish
|
||||
|
||||
- **Keyboards:**
|
||||
- the server URL field gets a text keyboard with contact suggestions, not the URL keyboard
|
||||
([02](android/02-server-keyboard.png));
|
||||
- the port field gets the full letter keyboard ([z-port-keyboard](android/z-port-keyboard.png));
|
||||
- hostname and username get autocorrect suggestions.
|
||||
|
||||
Set `TextInputOptions.ContentType` (`Url`, `Digits`) and turn autocorrect off for those.
|
||||
- **Placeholder-as-label:** name, hostname, username and notes in the host editor, the unlock field,
|
||||
and the snippet fields have no labels, so each field's meaning disappears once typed in.
|
||||
- **Terminology:**
|
||||
- "CREATE THE VAULT", then "Creating your keychain";
|
||||
- "vault passphrase" (placeholder) next to "Enter your keychain passphrase" on the same screen;
|
||||
- "ENROLLMENT" (US spelling) in a British-English app ("Synchronised", "unopenable").
|
||||
- **Fingerprint wrapping:** a single "I" wraps onto its own line. Chunk it, or size it to fit
|
||||
([13](android/13-host-key.png)).
|
||||
- **Address format:** "10.0.2.2 · 2222" on the host-key sheet, "10.0.2.2:2222" everywhere else.
|
||||
- **Dates:** "10/2/2026 11:29 PM" in Logs ([23](android/23-logs.png)), "2026-10-02 21:04" in SFTP.
|
||||
- **Notification permission:** Android's prompt appears without explanation at the first connect.
|
||||
Explain the keep-alive notification first.
|
||||
- **Terminal width:** the default size gives about 37 columns, so `ls -la` wraps every line.
|
||||
- **Empty and loading states:**
|
||||
- Hosts' empty state is one grey line at the top left ([06](android/06-hosts-empty.png));
|
||||
- Vaults first rendered an empty list with no loading indicator;
|
||||
- the keychain shows both "0 items" and an empty-state sentence.
|
||||
- **Connecting card:** **CLOSE THIS TAB** and **SHOW LOGS** are indented past the card's left edge
|
||||
([11](android/11-connecting.png)).
|
||||
- **Host-key context:** the sheet opened over the Quick Connect form, not over the connection progress
|
||||
it belongs to.
|
||||
- **Disabled fields:** on the enrolment busy state they turn a flat mid-grey
|
||||
([04](android/04-enrol-busy.png)), and the keyboard stays up.
|
||||
- **Fingerprint setting:** "Unlock with a fingerprint" in Preferences is drawn as a bare circle; it reads
|
||||
as a radio button, not a switch.
|
||||
|
||||
## Not verified here
|
||||
|
||||
- **System font size:** whether the app honours it. No `FontScale` handling exists in the phone code,
|
||||
and Avalonia scales by density, so text likely ignores the setting. I didn't change device settings
|
||||
to confirm.
|
||||
- **Landscape and tablet:** an `IsWide` layout exists but wasn't exercised.
|
||||
- **TalkBack:** not run end to end. The H4 findings come from the tree TalkBack reads.
|
||||
|
||||
## Environment notes for a re-run
|
||||
|
||||
- **Keycloak port:** 18080 was taken by another project's container, so Keycloak ran on **18180** via a
|
||||
compose override, with `Oidc__Authority=http://127.0.0.1:18180/realms/dodossh` and
|
||||
`adb reverse tcp:18180`.
|
||||
- **`adb root`** restarts adbd and drops `adb reverse`. Re-add both forwards afterwards, or the app
|
||||
shows OFFLINE.
|
||||
- **The sshd** was a throwaway `linuxserver/openssh-server` container (`dodossh-audit-sshd`, port 2222,
|
||||
user `dodo`).
|
||||
- **Build:** `-p:RuntimeIdentifier=android-x64` adds an `android-x64` section to every
|
||||
`packages.lock.json`. Restore those files afterwards.
|
||||
@@ -0,0 +1,147 @@
|
||||
# DodoSSH full sweep — 2026-10-02
|
||||
|
||||
## Fix status (same day)
|
||||
|
||||
Every fix below has a regression test. Where marked ✔✔, the test was also checked to fail with the fix taken out.
|
||||
|
||||
| Item | Fix |
|
||||
|---|---|
|
||||
| C1 ✔✔ | `LocalDirectory.ChildPath` cleans remote names and requires the resolved parent to be the target folder. It is used by every download path and by EDIT. `LocalEditor` opens a file that would run (PATHEXT plus a known list, and macOS launchers) with the type's own `edit` verb or with Notepad / `open -t`. On Linux, opening a `.desktop` file is refused. |
|
||||
| C2 ✔✔ | Pinned key types are offered first. A key under an algorithm with no pin of its own is accepted only if its fingerprint matches one of the endpoint's pins (the same RSA key under `rsa-sha2-256` vs `-512`); otherwise it is a hard mismatch. Tested against the real OpenSSH container. |
|
||||
| C3 ✔✔ | Complete, Revise and Park take the `PendingOperation` and act only while the row still holds that operation id. A later edit is rebased onto the version that was just accepted, so it pushes without a conflict. |
|
||||
| C4 | Pull leaves out change entries the item has moved past, so a revision is never paired with newer ciphertext. This also stops re-sending an item's payload once per historical edit. |
|
||||
| C5 | Deleting a team's last vault archives the team only when the owner does it. `TeamService.ArchiveAsync` now checks ownership itself. |
|
||||
| C6 | `DeviceKeyNames`: the default profile keeps `DodoSSH.DeviceKey.v1`, and every other profile gets its own name. Sign-out deletes only its own key. A profile still sealed under the shared key keeps reading it. The same applies to the macOS keychain tag. |
|
||||
| H1–H3 | The data plane reassembles messages up to 16 MiB. A frame that throws stays with its own session. Input writes are serialized per pump, so a slow write is never overtaken. |
|
||||
| H4 | The host and bucket the user chose are remembered across reloads. A typed password is only sent to the host it was typed for. |
|
||||
| H5 | Selecting a vault reads its people even while the screen is busy. A generation counter discards answers for an older selection. |
|
||||
| H6 / U9 | Locking or signing out leaves Settings, and the Members panel closes with it. Escape closes the Members panel first. |
|
||||
| H7 ✔✔ | The tracked user is reloaded after the out-of-band last-seen update. |
|
||||
| H8 | `sync_state.pulled_types` (with a client migration) restarts the pull once when the synced type list changes. |
|
||||
| H9 | `ActiveAccountRequirement` on both policies. A suspended account gets 403 `account-not-active`. |
|
||||
| H10 | `DevicePinWindow` palette keys fixed, and it now uses `InitializeComponent`. `Load` never assigned the named fields, so the window could never have opened. Covered by a new layout test. |
|
||||
| H11 ✔✔ | `IRemoteFileStore.CanWriteFromAnOffset`: false for buckets, so a retried upload starts over. |
|
||||
| H12 | `ImportOutcome` carries refused hosts and the reason a batch stopped early, including a busy keychain. Both import screens say so. |
|
||||
| U1–U4 | The host card's sync badge moved to the address line, so names show in full. Tag chips wrap and trim. Keychain names trim inside their column, with a tooltip. The Security caption wraps. |
|
||||
|
||||
Second round, on branch `claude/audit-followups`:
|
||||
|
||||
| Item | Fix |
|
||||
|---|---|
|
||||
| U5 ✔✔ | The closed vault's card takes the keyboard on launch and on each new card (server address, enrolment, passphrase). Enter signs in and enrols. |
|
||||
| U6 ✔✔ | The host-key, delete and sign-out questions give the keyboard to their harmless answer (CANCEL / CLOSE, never TRUST or DELETE). Escape says no to each, and closes the hosts drawer. Tab stays inside the host-key card. |
|
||||
| U7 ✔✔ | Arrow-key selection on the Hosts board is written back to the vault, from the list that has the keyboard only. An arrow on the board itself enters the cards. Enter on a card connects. |
|
||||
| U8 | `DeviceUnlockWording`: Windows Hello, Touch ID, a TPM-checked device PIN or a fingerprint, according to the platform. |
|
||||
| U10 | Session tab labels are capped at 200 px and trimmed, with the full label in a tooltip. |
|
||||
| U11 | The keychain's VAULT column is an Auto column in a shared size group, so it takes no width when no row names a vault. The Tags page's always-empty column is gone. The snippet buttons appear only with a snippet chosen. |
|
||||
| U12 ✔✔ | `TerminalTabViewModel.EndedAt`: the status-bar timer stops when the shell ends. |
|
||||
| P1 (part) ✔✔ | A sync pass that moved nothing no longer reloads (and decrypts) the whole keychain. It restrings the ago-texts instead. Failed vaults still reload. The Clear-plus-Add rebuild itself is unchanged. |
|
||||
| P3 | `ProbedDeviceKeyStore`: the TPM probe runs once per process, on the thread pool. |
|
||||
| P5 | A pulled page is mirrored in one write, and the outbox is read once per page. Changes with a local edit waiting still go one at a time, in order. |
|
||||
| P6 ✔✔ | `OpenShellAsync` makes its three round trips on the thread pool, honours cancellation, and closes a shell that arrives after the caller gave up. |
|
||||
| Transfers ✔✔ | Lost wake-up fixed with a `pumping` flag cleared under the same lock that finds the queue empty (a stress test failed 2 of 2 runs before the fix). Cancel as a transfer finishes no longer throws. A drop or download refused while busy says so. |
|
||||
| EF retry ✔✔ | `FreshAttempts.ExecuteFreshAsync`: a retried write transaction clears the tracker and reloads what it carries in. Before this, a lost commit made a team hand-over answer 204 with nothing changed. Tested with an interceptor that fails one commit. |
|
||||
| Rejected delete ✔✔ | A delete of a create that never landed is complete when the server says it has no such item. A create that did land still behaves as before: the delete gives way, and the user is told. |
|
||||
| Team archive race | Archive and team-vault creation take a per-team advisory lock. Archive re-counts the team's vaults, and creation re-checks the team is live. No race test. |
|
||||
| Relay revocation ✔✔ | `RelayAccessWatch` re-checks a user's open relay sessions when their access changes and ends lost ones with 4403 / `AccessRevoked`. |
|
||||
| Shell | `ToggleVaultAsync` catches a failure and redraws. `CrashLog` writes uncaught exceptions to `crash.log` in the profile (the process still exits). A lock pressed during an unlock now wins (✔✔). |
|
||||
| Window chrome | `CaptionPresses` decides a double-click on the press, before `BeginMoveDrag`. Only a headless test covers it; it is not verified on a real Windows window. |
|
||||
|
||||
| P1 (rest) ✔✔ | `ReplaceableCollection.ReplaceAll`: the host, visible-host, section and bucket lists are swapped in whole and announced once, and each transfers picker refreshes only from its own list. |
|
||||
| P2 (most) | The transfer queue and import lists are virtualised (✔✔: 2,000 of 2,000 rows were built before). The find box hands its text to the vault after a 150 ms pause in typing, and the view model still filters synchronously. |
|
||||
|
||||
| P2 (board) ✔✔ | `VirtualizingWrapPanel` builds only the host cards near the view. Measured headless, after warm-up: first layout with 300 hosts went from about 3 s to about 0.5 s, and with 1,000 from 8.3 s to 0.2 s. Clearing a filter went from 1–8 s to about 0.1–0.2 s. Arrow keys, End, the band and selection still work. |
|
||||
|
||||
| P4 ✔✔ | **Server:** `TombstoneCollector` runs every 6 h. It deletes tombstones past the 90-day retention together with their change entries, and compacts each item's superseded entries down to its newest. It then raises the vault's `SyncHorizon`, and a cursor from before it is refused. A cursor now carries the horizon it was issued under. Without that, the read from the beginning that a refused client starts was refused on its own second page and looped for ever on any vault larger than a page (✔✔: found by a client test, then reproduced in an API test). **Client** ("resync and prune", as chosen): a read from the beginning marks every item it reads, keeping the mark in the sync state because the read can span passes. When it finishes, it removes unmarked items of the synced types. It keeps items with an unsent change, and items pushed during the read. Such an edit is then sent as a create, as before, but now records a `RecreatedAfterCollection` conflict and is named in the sync message. The removed items are named too. New client cache migration `AddSweepMarks`. |
|
||||
|
||||
Not done, and why:
|
||||
- **Live-run observations** (production as the default server URL, Keycloak on host port 18080): product decisions, not bugs.
|
||||
|
||||
Pre-existing flakiness: on untouched HEAD, 3 of 8 runs of `ShellFlowTests` each had one test fail: `TheCountsAPhoneShows_FollowEveryForwardsState`, `ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself` and `ReconnectingAHostThatWantsAPassword_AsksForOneRatherThanDiallingWithout`. These races are in the tests and predate this work.
|
||||
|
||||
How it was done:
|
||||
- The desktop client ran on Windows with an isolated profile against a local stack: Postgres, Keycloak and the API on :5333.
|
||||
- The real MainWindow was rendered headless through every screen and settings page, 39 captures at 1081×583 and 1440×900.
|
||||
- Four parallel code audits covered: the Shell view models; SSH/terminal/transfer; the server and sync; the desktop views and platform code.
|
||||
- The findings marked **✔ verified** were re-read in code by me, or seen in a screenshot. The rest come from the audits; each one was traced, but I did not check them again.
|
||||
|
||||
The live session could not get past OIDC sign-in. The default browser is Edge, which I can't type into, so the terminal, SFTP and host-key flows were reviewed in code only.
|
||||
|
||||
---
|
||||
|
||||
## Critical — data loss, security
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| C1 | **Path traversal on download / open-to-edit.** Remote names go straight into `Path.Combine(LocalPath, row.Name)`. SFTP only drops `.` and `..`, so on Windows a name with backslashes (`..\..\AppData\…\Startup\x.bat`) or a rooted name escapes the folder. S3 keys `../..` do the same on every OS. EDIT then opens the file with `UseShellExecute`, so a `.bat`/`.hta` *runs*. The Android head sanitises names; the desktop doesn't. ✔ verified | `TransfersViewModel.cs:2682,2698,2780,2784,3165`; `SshNetSftpSession.cs:105`; `ObjectKeys.cs:50`; `App.axaml.cs:380` |
|
||||
| C2 | **Host-key pin bypass by changing the key algorithm.** Pins are looked up per algorithm. A MITM that offers ecdsa or rsa to a host pinned for ed25519 gets the "never seen before" first-use prompt instead of the mismatch hard block. ✔ verified | `SshNetConnectionFactory.cs:261-272` |
|
||||
| C3 | **The outbox loses an edit made while a push is in flight.** `CompleteAsync` deletes the row by `Sequence` only, but `QueueAsync` folds a newer edit into that same row with a new `OperationId`. When the older push is acknowledged, the newer edit is deleted silently. ✔ verified | `OutboxStore.cs:239-250` vs `:71-97`; `SyncEngine.cs:554` |
|
||||
| C4 | **Pull pairs historical revision numbers with the current ciphertext.** `Describe` sends `Version: change.Revision`, but the payload comes from the item's current row. The AAD binds the version, so the payload fails to decrypt. A concurrent offline edit gets parked as "Undecryptable". ✔ verified | `SyncService.cs:565,592` |
|
||||
| C5 | **A team admin can archive the team (meant to be owner-only).** If an admin deletes the last vault, `ArchiveAsync` runs with no role check and revokes every membership, the owner's included. | `VaultGrantEndpoints.cs:182,196`; `TeamService.cs:242-289` |
|
||||
| C6 | **The shared TPM key name breaks other profiles.** `WindowsDeviceKeyStore` uses one CNG key name (`DodoSSH.DeviceKey.v1`) for the release and nightly profiles. Sign-out in one (`ForgetAsync`) deletes the key, and the other profile's device unlock stops working. This contradicts `ClientPaths.ForChannel`, which says the two installs are kept apart. ✔ verified | `WindowsDeviceKeyStore.cs:127,233-246` |
|
||||
|
||||
## High — broken behaviour
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| H1 | **Terminal input >64 KiB (a large paste or copy) is lost.** On a frame that doesn't fit the buffer, the loop `continue`s, and the tail is then dispatched as if it were a frame. ✔ verified | `TerminalDataPlane.cs:465-472` |
|
||||
| H2 | **Keystrokes can race.** Input frames are written to `ShellStream` without awaiting and without a lock, and the base `FlushAsync` runs on the pool. A snippet RUN (text, then `\r`) over about 4 KiB can execute truncated. | `TerminalDataPlane.cs:505`; `SshNetConnectionFactory.cs:554-558` |
|
||||
| H3 | **One resize on a closing shell tears down the renderer socket for every tab** (`ObjectDisposedException` escapes the receive loop). | `TerminalDataPlane.cs:516-520` |
|
||||
| H4 | **The SFTP host picker resets to the first host on every sync pass**, about once a minute. Selection is lost on `Hosts.Clear()`, so CONNECT can dial host[0] with the password typed for host X. The same Clear-then-Add pattern resets the Known Hosts and Snippets selection and closes the snippet delete card. | `TransfersViewModel.cs:3905,3923-3950`; `KnownHostsViewModel.cs:264`; `SnippetsViewModel.cs:689` |
|
||||
| H5 | **The Vaults Members panel can show vault A's members under vault B**, and SHARE / REVOKE then act on that mix. | `VaultsViewModel.cs:1646,1698-1765` |
|
||||
| H6 | **Locking or signing out from Settings leaves a window with no title bar** (no close, minimize or drag). Unlock then returns to Settings. ✔ verified by screenshot | `MainWindowViewModel.cs:3254,3400`; see `locked-from-settings-no-titlebar.png` |
|
||||
| H7 | **Enrollment returns 500 if last-seen is more than an hour old.** `ExecuteUpdate` changes `xmin` under a tracked entity, and the `SaveChanges` that follows throws a concurrency exception. | `CurrentUserContext.cs:92,120`; `EnrollmentService.cs:177` |
|
||||
| H8 | **A new synced type never back-fills.** A filtered pull moves the cursor past types the client didn't request, so after an upgrade adds a type (SpelunkKey, ObjectStore), older changes of that type never arrive. | `SyncService.cs:50,69`; `SyncEngine.cs:145` |
|
||||
| H9 | **Suspended users keep full access.** `UserStatus` is checked nowhere on the request path. | `CurrentUserContext.cs:126` |
|
||||
| H10 | **`DevicePinWindow` references `Surface` and `MutedText`, which don't exist**, so Linux TPM-PIN register/unlock should throw on open. ✔ verified | `DevicePinWindow.axaml:7,35` |
|
||||
| H11 | **A failed S3 upload commits a truncated part, and every retry then fails.** | `S3UploadStream.cs:126-146` |
|
||||
| H12 | **Import screens report success when the vault skipped or failed the import** (busy, or a mid-batch exception). | `ImportViewModel.cs:374`; `HetznerImportViewModel.cs:561` |
|
||||
|
||||
## UI issues seen in the running app
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| U1 | **Host card labels are cut to 3-4 characters even at 1440px.** "bastion" shows as "bast…", and k8s-worker-a / -b both show as "k8s…", as do web-prod-01 / -02. The label is the primary identifier, and the "not synced" badge takes most of its width. ✔ `hosts-truncated-labels.png` | `HostsScreen.axaml` card template |
|
||||
| U2 | **Tag chips overflow the host card edge.** The WrapPanel sits inside a horizontal StackPanel, so it never wraps. ✔ | `HostsScreen.axaml:737-774` |
|
||||
| U3 | **Keychain table: long names overprint the TYPE / USED BY columns.** `TextTrimming` has no effect inside a horizontal StackPanel. The same pattern appears in Logs, SessionSidebar, Snippets, NavRail and Transfers. ✔ `keychain-overlap.png` | `KeychainScreen.axaml:294` et al. |
|
||||
| U4 | **Security page: the "End-to-end encrypted" caption runs past the card edge.** ✔ `security-text-clipped.png` | `SettingsSecurityPage.axaml:49-60` |
|
||||
| U5 | **Enter does nothing in the server URL box** (verified live). The same applies to the enrollment passphrase boxes. The passphrase box is not focused on launch. | `MainWindow.axaml:385` |
|
||||
| U6 | **Overlay cards (host key, delete, sign-out, host editor) have no Enter/Escape and don't take focus**, and Tab walks the page behind them. | `HostKeyCard.axaml` etc. |
|
||||
| U7 | **Hosts board: arrow-key selection isn't written back to the view model**, so the drawer's Connect / Delete act on the previously clicked host. There is no Enter-to-connect. | `HostsScreen.axaml:573` |
|
||||
| U8 | **Windows-only wording on macOS/Linux:** "USE WINDOWS HELLO", "Waiting for Windows…". | `UnlockCard.axaml:51`; `MainWindowViewModel.GestureWait` |
|
||||
| U9 | **Escape over the Members panel leaves Settings and leaves the panel open** for the next visit. | `MainWindow.axaml.cs:242` |
|
||||
| U10 | **Session tab labels are unbounded.** One long label pushes "+" off the strip. | `SessionTabRow.axaml:89` |
|
||||
| U11 | **A VAULT column is drawn and empty** with a single vault (Keychain, Tags). The snippets list footer shows Edit / Move / Delete with nothing selected. | screenshots |
|
||||
| U12 | **The status-bar session timer keeps counting after a shell has ended.** | `MainWindowViewModel.cs:4979` |
|
||||
|
||||
## Performance
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| P1 | **Every sync pass, about once a minute, decrypts the entire keychain even when nothing changed**, then rebuilds each list with Clear + Add. That is O(n²) notification fan-out: with 300 hosts, roughly 45k collection notifications per pass. | `VaultViewModel.cs:6375,4709`; `MainWindowViewModel.cs:4549` |
|
||||
| P2 | **The Hosts board is not virtualised and is rebuilt on every filter keystroke.** The transfer queue (one row per file in a folder download) and the import lists are not virtualised either. | `HostsScreen.axaml:448-576`; `TransfersScreen.axaml:1193` |
|
||||
| P3 | **The TPM probe (create and delete an RSA-2048 key) runs synchronously on the UI thread** at startup and after every unlock. | `WindowsDeviceKeyStore.cs:152`; `MainWindowViewModel.cs:2594,2970` |
|
||||
| P4 | **Pull re-sends the full current payload once per historical change.** There is no change-log compaction and no tombstone GC, even though ADR 0003 promises 90-day GC. | `SyncService.cs:45-56,565` |
|
||||
| P5 | **The client applies each pulled change in 2 or more separate SQLite transactions.** | `SyncEngine.cs:157-169,303` |
|
||||
| P6 | **`OpenShellAsync` does blocking network round trips wrapped in `Task.FromResult`** and ignores cancellation. | `SshNetConnectionFactory.cs:401-418` |
|
||||
|
||||
## Lower / plausible
|
||||
|
||||
- **Transfers:**
|
||||
- A wake-up can be lost, leaving a transfer queued forever (`FileTransferQueue.cs:452-505`).
|
||||
- Cancel at the moment a transfer finishes can throw `ObjectDisposedException`.
|
||||
- Drops and downloads are ignored silently while busy (`TransfersViewModel.cs:2272`).
|
||||
- **Sync and server:**
|
||||
- The EF retry strategy re-runs a push with stale tracked state (`Persistence.cs:38`).
|
||||
- Deleting a create that never landed leaves a permanent "Rejected" conflict.
|
||||
- A team can be archived while a vault is being created in it.
|
||||
- Relay sessions survive removal from the team, for up to 12 hours.
|
||||
- **Shell:**
|
||||
- Lock can race an unlock that is still loading.
|
||||
- `ToggleVaultAsync` has no try/catch, and there is no global `UnhandledException` handler.
|
||||
- **Window chrome:** double-click to maximise may be swallowed by `BeginMoveDrag`.
|
||||
|
||||
## Observations from the live run
|
||||
|
||||
- **The first-launch default is the production server, and sign-in completes without a credential prompt.** `DefaultServerUrl = "https://ssh.dodotech.cloud"` is pre-filled. With a live browser SSO session, one press of SIGN IN on a brand-new profile completed sign-in with no password typed, and wrote the account's wrapped key and vault list into the new profile. For a self-hosted product, an empty field with a placeholder is the safer default.
|
||||
- **The local dev compose hard-codes host port 18080 for Keycloak**, which collides with other projects on this machine.
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -622,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,10 @@ builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||
builder.Services.AddSingleton<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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=""
|
||||
AutomationProperties.Name="Keys"
|
||||
Command="{Binding Keyboard.SelectTabCommand}"
|
||||
CommandParameter="{x:Static vm:QuickAccessTab.Keys}" />
|
||||
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsSnippetsTab}" Content=""
|
||||
AutomationProperties.Name="Snippets"
|
||||
Command="{Binding Keyboard.SelectTabCommand}"
|
||||
CommandParameter="{x:Static vm:QuickAccessTab.Snippets}" />
|
||||
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsHistoryTab}" Content=""
|
||||
AutomationProperties.Name="History"
|
||||
Command="{Binding Keyboard.SelectTabCommand}"
|
||||
CommandParameter="{x:Static vm:QuickAccessTab.History}" />
|
||||
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsAppearanceTab}" Content=""
|
||||
AutomationProperties.Name="Appearance"
|
||||
Command="{Binding Keyboard.SelectTabCommand}"
|
||||
CommandParameter="{x:Static vm:QuickAccessTab.Appearance}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Back to the phone's own keyboard. The one control here that is not a tab, so it sits apart behind a
|
||||
hairline: it does not change what this panel shows, it puts the panel away.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
|
||||
<Border Width="1" Height="22" Background="{StaticResource Border}" VerticalAlignment="Center" />
|
||||
<Button Classes="qtab" Content="" AutomationProperties.Name="Show the keyboard"
|
||||
Click="OnKeyboardRequested" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<!-- ============ KEYS ============ -->
|
||||
<ScrollViewer IsVisible="{Binding Keyboard.IsKeysTab}" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="8,4,8,12">
|
||||
|
||||
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center"
|
||||
Command="{Binding Keyboard.OpenCustomizeCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text=""
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="label" FontSize="10" Text="CUSTOMIZE" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
The bar's own keys, first, when the bar is not drawn — which is a wide screen with this panel
|
||||
open beside the terminal. A key somebody put on their bar is one they want within reach, and
|
||||
hiding the bar must not hide it.
|
||||
-->
|
||||
<StackPanel IsVisible="{Binding $parent[views:QuickAccessKeyboard].ShowsBarGroup}">
|
||||
<TextBlock Classes="groupcaption" Text="YOUR BAR" />
|
||||
<ItemsControl ItemsSource="{Binding Keyboard.BarRows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyRowViewModel">
|
||||
<ItemsControl ItemsSource="{Binding Keys}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyButtonViewModel">
|
||||
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
|
||||
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
|
||||
Command="{Binding PressCommand}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Keyboard.Groups}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyGroupViewModel">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="groupcaption" Text="{Binding Title}" />
|
||||
<!--
|
||||
Four across, which at 360dp is an 84dp key — wide enough for "shift+tab" and "ctrl+B %"
|
||||
in the mono face without shrinking it, and still four keys a thumb can tell apart.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding Keys}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyButtonViewModel">
|
||||
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
|
||||
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
|
||||
Command="{Binding PressCommand}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ SNIPPETS ============ -->
|
||||
<!--
|
||||
The keychain's snippets, through the shell's own InsertSnippet — the command the desktop sidebar's
|
||||
rows run, which carries the snippets screen's whole safety story: typed rather than pasted for one
|
||||
line, and no Enter unless the snippet was saved as one that runs. A second insert path here would be
|
||||
a second place that story could go stale. The ▶ says which ones run.
|
||||
-->
|
||||
<ScrollViewer IsVisible="{Binding Keyboard.IsSnippetsTab}" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="8,4,8,12" Spacing="2">
|
||||
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible, FallbackValue={x:Null}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<Button Classes="row" MinHeight="48" Padding="12,6"
|
||||
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Label}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Preview}"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="label" FontSize="9" Text="▶ RUNS" Margin="10,0,0,0"
|
||||
Foreground="{StaticResource WarnText}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding RunsOnInsert}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||
Text="Tap a snippet to type it at the prompt. Snippets live in the keychain, where they are added and edited." />
|
||||
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
|
||||
Command="{Binding ShowScreenCommand}" CommandParameter="{x:Static vm:ShellScreen.Snippets}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="MANAGE SNIPPETS" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ HISTORY ============ -->
|
||||
<!--
|
||||
What was typed on this phone and echoed back by the remote — never a line typed at a password prompt,
|
||||
which echoes nothing. Kept in memory only and emptied by LOCK; the sentence under the list says so,
|
||||
because a list of commands is exactly the kind of thing somebody should know the lifetime of. See
|
||||
TypedCommandHistory for the whole of what is and is not kept.
|
||||
-->
|
||||
<ScrollViewer IsVisible="{Binding Keyboard.IsHistoryTab}" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="8,4,8,12" Spacing="2">
|
||||
<ItemsControl ItemsSource="{Binding Keyboard.History}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="kb:HistoryEntry">
|
||||
<Button Classes="row" MinHeight="44" Padding="12,6"
|
||||
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).Keyboard.InsertHistoryCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5" Text="{Binding Command}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="detail" FontSize="10" Text="{Binding Host}"
|
||||
Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !Keyboard.HasHistory}"
|
||||
Text="Commands you type here appear in this list. Only what the remote echoes back is kept, so nothing typed at a password prompt ever is." />
|
||||
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||
Text="Tap a command to type it at the prompt without running it. The list is kept in memory only, and locking the keychain clears it." />
|
||||
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
|
||||
IsVisible="{Binding Keyboard.HasHistory}"
|
||||
Command="{Binding Keyboard.ClearHistoryCommand}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLEAR HISTORY" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ APPEARANCE ============ -->
|
||||
<!--
|
||||
The text size, which used to ride at the end of the arrow row as A− and A+. A phone cannot press
|
||||
Ctrl+plus, so these are still buttons; they moved here because the bar is now somebody's own and two
|
||||
fixed cells at its end were cells they could not have. Disabled at the ends rather than clamping
|
||||
silently, for the reason they always were: a button that accepts taps and does nothing reads as the
|
||||
terminal having stopped responding.
|
||||
|
||||
And the cursor gesture's switch, for somebody whose long press means something else to them.
|
||||
-->
|
||||
<ScrollViewer IsVisible="{Binding Keyboard.IsAppearanceTab}" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="12,6,12,12" Spacing="8">
|
||||
|
||||
<TextBlock Classes="groupcaption" Text="TEXT SIZE" Margin="0,4,0,0" />
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Button Grid.Column="0" Classes="hotkey" Width="56" Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="14" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Text="{Binding TerminalFontSize, StringFormat='{}{0} px'}" />
|
||||
<Button Grid.Column="2" Classes="hotkey" Width="56" Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
<Button Grid.Column="3" Classes="row" MinHeight="40" Padding="12,0" Margin="8,0,0,0"
|
||||
Command="{Binding ResetTerminalFontCommand}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="RESET" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="groupcaption" Text="CURSOR GESTURE" Margin="0,10,0,0" />
|
||||
<Button Classes="row" MinHeight="52" Padding="12,8" Command="{Binding Keyboard.ToggleCursorGesturesCommand}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="Long-press and drag" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Moves the cursor or walks through history. Drag further to go faster." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="ON" Margin="10,0,0,0"
|
||||
Foreground="{StaticResource Live}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Keyboard.CursorGestures}" />
|
||||
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="OFF" Margin="10,0,0,0"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Keyboard.CursorGestures}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Panel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,40 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>The quick-access keyboard: keys, snippets, history and appearance, in four tabs.</summary>
|
||||
internal sealed partial class QuickAccessKeyboard : UserControl
|
||||
{
|
||||
/// <summary>Whether the KEYS tab repeats the bar's own keys at the top.</summary>
|
||||
/// <remarks>
|
||||
/// On for the wide screen's side panel, where the bar is not drawn; off under a phone's terminal, where
|
||||
/// the bar is directly above this and a second copy would be two rows of the same keys.
|
||||
/// </remarks>
|
||||
public static readonly StyledProperty<bool> ShowsBarGroupProperty =
|
||||
AvaloniaProperty.Register<QuickAccessKeyboard, bool>(nameof(ShowsBarGroup));
|
||||
|
||||
public QuickAccessKeyboard() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
/// <summary>
|
||||
/// The keyboard button in the tab strip was pressed: put this panel away and bring the phone's back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An event rather than a command, because both halves are platform calls the shared view model has no
|
||||
/// business making — showing the software keyboard means focusing the terminal's input surface, which
|
||||
/// only <see cref="TerminalScreen"/> holds.
|
||||
/// </remarks>
|
||||
public event EventHandler? KeyboardRequested;
|
||||
|
||||
/// <inheritdoc cref="ShowsBarGroupProperty" />
|
||||
public bool ShowsBarGroup
|
||||
{
|
||||
get => GetValue(ShowsBarGroupProperty);
|
||||
set => SetValue(ShowsBarGroupProperty, value);
|
||||
}
|
||||
|
||||
private void OnKeyboardRequested(object? sender, RoutedEventArgs e) =>
|
||||
KeyboardRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -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=""
|
||||
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=""
|
||||
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="" />
|
||||
</Button>
|
||||
<Button Grid.Column="3" Classes="icon" Height="40" Width="40"
|
||||
AutomationProperties.Name="Move later" Command="{Binding MoveDownCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||
</Button>
|
||||
<Button Grid.Column="4" Classes="icon" Height="40" Width="40"
|
||||
AutomationProperties.Name="Take off the bar" Command="{Binding RemoveCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Classes="body" TextWrapping="Wrap" IsVisible="{Binding !Keyboard.HasBar}"
|
||||
Text="The bar is empty, so only the grid button is drawn above the keyboard." />
|
||||
|
||||
<!-- ── the groups ── -->
|
||||
<TextBlock Classes="label" Text="GROUPS" Margin="0,16,0,0" />
|
||||
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||
Text="The quick-access keyboard shows the ticked groups, in this order. A lit key is on the bar." />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Keyboard.GroupEditor}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupEditorRowViewModel">
|
||||
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,6,0,10">
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsShown}" MinHeight="40">
|
||||
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Title}" />
|
||||
</CheckBox>
|
||||
<Button Grid.Column="1" Classes="icon" Height="40" Width="40"
|
||||
AutomationProperties.Name="Move up" Command="{Binding MoveUpCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||
</Button>
|
||||
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
|
||||
AutomationProperties.Name="Move down" Command="{Binding MoveDownCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||
</Button>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding Keys}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyChoiceViewModel">
|
||||
<Button Classes="chiptoggle" Classes.worn="{Binding IsOnBar}" Margin="0,0,6,6"
|
||||
Command="{Binding ToggleCommand}">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- ── keys of one's own ── -->
|
||||
<TextBlock Classes="label" Text="ADD A KEY" Margin="0,16,0,0" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Keyboard.CustomKeyEditor}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CustomKeyRowViewModel">
|
||||
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,4">
|
||||
<Grid ColumnDefinitions="64,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||
Text="{Binding Label}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Classes="detail" FontSize="10.5" Text="{Binding Sequence}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
|
||||
AutomationProperties.Name="Delete this key" Command="{Binding DeleteCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||
Foreground="{StaticResource DangerText}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
ShowSuggestions off on both boxes, for the reason the terminal's own input surface turns it off:
|
||||
an autocorrecting keyboard capitalises the e in \e and puts a full stop after ^C, and the key
|
||||
then sends something nobody typed.
|
||||
-->
|
||||
<TextBox Classes="field" Text="{Binding Keyboard.NewKeyLabel}" MaxLength="10"
|
||||
TextInputOptions.ShowSuggestions="False"
|
||||
PlaceholderText="Label, as it appears on the key" />
|
||||
<TextBox Classes="field" Text="{Binding Keyboard.NewKeySequence}"
|
||||
TextInputOptions.ShowSuggestions="False"
|
||||
PlaceholderText="What it sends, e.g. \e:wq\r or ^C" />
|
||||
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||
Text="\e is Escape, \r is Enter, \t is Tab, and ^ before a letter is Ctrl; \^ is a plain caret. Keys are kept on this phone in plain text, so this is no place for a password." />
|
||||
<Button Classes="primary" Content="ADD KEY" Command="{Binding Keyboard.AddCustomKeyCommand}" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding Keyboard.CustomizeStatus}"
|
||||
IsVisible="{Binding Keyboard.CustomizeStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<Button Classes="secondary" Content="RESET TO DEFAULTS" Margin="0,16,0,0"
|
||||
Command="{Binding Keyboard.ResetLayoutCommand}" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||
Text="Puts the bar and the groups back as they shipped. Keys you added are kept." />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
</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 & 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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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 & 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 };
|
||||
|
||||
@@ -3815,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
|
||||
@@ -4230,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,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; }
|
||||
}
|
||||
@@ -6451,6 +6451,8 @@ internal sealed partial class VaultViewModel(
|
||||
|| pass.PushedItems > 0
|
||||
|| pass.Merged > 0
|
||||
|| pass.Resurrected > 0
|
||||
|| pass.Recreated > 0
|
||||
|| pass.Pruned > 0
|
||||
|| pass.DeletesAbandoned > 0
|
||||
|| pass.Parked > 0
|
||||
|| pass.ResyncedFromStart);
|
||||
@@ -13159,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
|
||||
@@ -13175,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");
|
||||
@@ -13200,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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -177,6 +177,94 @@ public sealed class ItemStore(
|
||||
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");
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ public enum ConflictKind
|
||||
|
||||
/// <summary>The server refused the operation outright. Retrying will not help.</summary>
|
||||
Rejected = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Someone else deleted an item this machine had edited, so long ago that the server had already let go
|
||||
/// of the deletion. The edit was sent as the item again, under its own name and id.
|
||||
/// </summary>
|
||||
RecreatedAfterCollection = 7,
|
||||
}
|
||||
|
||||
/// <summary>What an offline unlock needs.</summary>
|
||||
@@ -191,7 +197,9 @@ public sealed record StoredSyncState(
|
||||
DateTimeOffset? LastPulledAt = null,
|
||||
DateTimeOffset? LastPushedAt = null,
|
||||
long ServerTimeSkewMs = 0,
|
||||
string? PulledTypes = null);
|
||||
string? PulledTypes = null,
|
||||
long? SweepMark = null,
|
||||
long SweepSeenUpTo = 0);
|
||||
|
||||
/// <summary>Something the merge overrode, or an item that could not be processed.</summary>
|
||||
/// <param name="Id">This record's own id, which its sealed detail is bound to.</param>
|
||||
|
||||
@@ -47,7 +47,9 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
|
||||
row.LastPulledAtUtc,
|
||||
row.LastPushedAtUtc,
|
||||
row.ServerTimeSkewMs,
|
||||
row.PulledTypes);
|
||||
row.PulledTypes,
|
||||
row.SweepMark,
|
||||
row.SweepSeenUpTo);
|
||||
}
|
||||
|
||||
/// <summary>Records a vault's position.</summary>
|
||||
@@ -74,6 +76,8 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
|
||||
row.LastPushedAtUtc = state.LastPushedAt;
|
||||
row.ServerTimeSkewMs = state.ServerTimeSkewMs;
|
||||
row.PulledTypes = state.PulledTypes;
|
||||
row.SweepMark = state.SweepMark;
|
||||
row.SweepSeenUpTo = state.SweepSeenUpTo;
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Contracts;
|
||||
@@ -155,23 +156,12 @@ public sealed class SyncEngine
|
||||
}
|
||||
|
||||
await ApplyPageAsync(vaultId, response.Changes, report, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var change in response.Changes)
|
||||
{
|
||||
report.Pulled++;
|
||||
|
||||
// Counted apart for the same reason the pushed ones are: a machine reads back the log
|
||||
// entries it just wrote, so a pass that "pulled five changes" may have carried nothing
|
||||
// anybody did. See SyncReport.PulledItems.
|
||||
if (IsLog(change.EntityType))
|
||||
{
|
||||
report.PulledLogEntries++;
|
||||
}
|
||||
}
|
||||
CountPulled(response.Changes, report);
|
||||
|
||||
var advanced = !string.Equals(state.Cursor, response.NextCursor, StringComparison.Ordinal);
|
||||
|
||||
state = Record(vaultId, state, response, report);
|
||||
state = await CarrySweepAsync(vaultId, state, response, report, cancellationToken).ConfigureAwait(false);
|
||||
await syncState.SaveAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.HasMore)
|
||||
@@ -188,15 +178,22 @@ public sealed class SyncEngine
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the server refused the cursor this client sent, rather than failing for some other reason.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read from the problem code and not from the prose, which is free to change, and never claimed for a
|
||||
/// request that carried no cursor: "from the beginning" is the one position a client is allowed to ask
|
||||
/// for, so a rejection of <em>that</em> is a server this code cannot reason about and has to surface.
|
||||
/// It is also what keeps the retry from looping — the restarted request sends no cursor.
|
||||
/// </remarks>
|
||||
private static void CountPulled(IReadOnlyList<SyncChange> changes, SyncReportBuilder report)
|
||||
{
|
||||
foreach (var change in changes)
|
||||
{
|
||||
report.Pulled++;
|
||||
|
||||
// Counted apart for the same reason the pushed ones are: a machine reads back the log entries it
|
||||
// just wrote, so a pass that "pulled five changes" may have carried nothing anybody did. See
|
||||
// SyncReport.PulledItems.
|
||||
if (IsLog(change.EntityType))
|
||||
{
|
||||
report.PulledLogEntries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether a change is one the machine wrote about itself rather than one somebody made.</summary>
|
||||
/// <remarks>
|
||||
/// Used only for reporting. Log entries sync exactly like every other item — they are pulled, pushed,
|
||||
@@ -206,10 +203,46 @@ public sealed class SyncEngine
|
||||
private static bool IsLog(SyncEntityType type) =>
|
||||
type is SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the server refused the cursor this client sent, rather than failing for some other reason.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read from the problem code and not from the prose, which is free to change, and never claimed for a
|
||||
/// request that carried no cursor: "from the beginning" is the one position a client is allowed to ask
|
||||
/// for, so a rejection of <em>that</em> is a server this code cannot reason about and has to surface.
|
||||
/// It is also what keeps the retry from looping — the restarted request sends no cursor.
|
||||
/// </remarks>
|
||||
private static bool WasTheCursorRefused(DodoSshApiException exception, string? cursor) =>
|
||||
!string.IsNullOrEmpty(cursor)
|
||||
&& string.Equals(exception.Code, ProblemCodes.InvalidCursor, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>Where the pull should carry on from, which is the start when the types it reads have changed.</summary>
|
||||
/// <remarks>
|
||||
/// A cursor read under a different list of types is past changes of the types it did not ask for, so an
|
||||
/// upgrade that adds one — SpelunkKey, ObjectStore — would never receive the items of that type made
|
||||
/// before it. Starting over once is the only way to see them; applying is idempotent, which is what makes
|
||||
/// the restart for a refused cursor safe as well. A state from before the list was kept has none and
|
||||
/// starts over once for the same reason: it may already have missed some.
|
||||
/// </remarks>
|
||||
private async Task<StoredSyncState> ReadStateAsync(
|
||||
Guid vaultId,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (state.Cursor is not null && !string.Equals(state.PulledTypes, PulledTypes, StringComparison.Ordinal))
|
||||
{
|
||||
return await StartOverAsync(state, report, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// A pull from nothing that is not a restart — the vault's first, or a cache reset — sweeps as well.
|
||||
// There is usually nothing to remove; the one time there is, it is the same case.
|
||||
return state is { Cursor: null, SweepMark: null }
|
||||
? await SweepingAsync(state, cancellationToken).ConfigureAwait(false)
|
||||
: state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets this vault's position and reads the log again from the beginning.
|
||||
/// </summary>
|
||||
@@ -234,44 +267,22 @@ public sealed class SyncEngine
|
||||
/// cache itself is the thing in doubt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That leaves one gap, and it is worth naming rather than leaving to be discovered: once the server
|
||||
/// starts collecting tombstones — <c>DodoOptions.TombstoneRetentionDays</c>, not implemented yet — a
|
||||
/// replay no longer carries a deletion older than the retention window. A machine that missed such a
|
||||
/// delete and then had its cursor refused would keep the row. Nothing here can tell that from a row
|
||||
/// the server still has, so the answer when it matters will be to clear the mirror as well, not to
|
||||
/// guess.
|
||||
/// A replay does not carry a deletion the server has collected — <c>DodoOptions.TombstoneRetentionDays</c>
|
||||
/// — and that is the commonest refusal of all: a machine away longer than the retention. The rows it no
|
||||
/// longer hears about are the ones the replay does not mark, and are swept when it ends; see
|
||||
/// <see cref="CarrySweepAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Written down before the replay begins, so a process that dies mid-replay starts the next one from
|
||||
/// the beginning as well, rather than meeting the same refusal again.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>Where the pull should carry on from, which is the start when the types it reads have changed.</summary>
|
||||
/// <remarks>
|
||||
/// A cursor read under a different list of types is past changes of the types it did not ask for, so an
|
||||
/// upgrade that adds one — SpelunkKey, ObjectStore — would never receive the items of that type made
|
||||
/// before it. Starting over once is the only way to see them; applying is idempotent, which is what makes
|
||||
/// the restart for a refused cursor safe as well. A state from before the list was kept has none and
|
||||
/// starts over once for the same reason: it may already have missed some.
|
||||
/// </remarks>
|
||||
private async Task<StoredSyncState> ReadStateAsync(
|
||||
Guid vaultId,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return state.Cursor is not null && !string.Equals(state.PulledTypes, PulledTypes, StringComparison.Ordinal)
|
||||
? await StartOverAsync(state, report, cancellationToken).ConfigureAwait(false)
|
||||
: state;
|
||||
}
|
||||
|
||||
private async Task<StoredSyncState> StartOverAsync(
|
||||
StoredSyncState state,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var restarted = state with { Cursor = null };
|
||||
var restarted = await SweepingAsync(state with { Cursor = null }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await syncState.SaveAsync(restarted, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -280,6 +291,68 @@ public sealed class SyncEngine
|
||||
return restarted;
|
||||
}
|
||||
|
||||
/// <summary>The state with a sweep begun: a new mark, and the bound set from what the cache holds now.</summary>
|
||||
/// <remarks>
|
||||
/// The mark is random rather than the time, because a sweep must never share one with the last: rows that
|
||||
/// one marked would count as seen by this one. A clock can stand still, or go back.
|
||||
/// </remarks>
|
||||
private async Task<StoredSyncState> SweepingAsync(StoredSyncState state, CancellationToken cancellationToken) =>
|
||||
state with
|
||||
{
|
||||
SweepMark = BitConverter.ToInt64(RandomNumberGenerator.GetBytes(sizeof(long))),
|
||||
SweepSeenUpTo = await items.HighestSequenceAsync(state.VaultId, cancellationToken).ConfigureAwait(false),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Marks what a pull from nothing has read, and at its end removes what it never did.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The server forgets a deletion once it is past its retention. A machine that was away longer is
|
||||
/// refused its cursor and reads everything again — and everything again is the items that still exist,
|
||||
/// with nothing said about the ones that went. Without this their rows stayed here for good, and the
|
||||
/// next edit of one put it back on the server for everybody.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A pull from nothing can take more than one pass, so the mark is in the stored state, not in memory.
|
||||
/// Every pull from nothing sweeps — the first one ever as well, where there is nothing to remove. What
|
||||
/// is kept, and why, is <see cref="ItemStore.SweepAsync"/>'s to say.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<StoredSyncState> CarrySweepAsync(
|
||||
Guid vaultId,
|
||||
StoredSyncState state,
|
||||
SyncPullResponse response,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (state.SweepMark is not { } mark)
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
var read = response.Changes.Where(change => reconcilers.ContainsKey(change.EntityType)).ToList();
|
||||
|
||||
await items.MarkSeenAsync(
|
||||
vaultId,
|
||||
mark,
|
||||
read.Select(change => (change.EntityType, change.EntityId)).ToList(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var seenUpTo = read.Aggregate(state.SweepSeenUpTo, (highest, change) => Math.Max(highest, change.ChangeSequence));
|
||||
|
||||
if (response.HasMore)
|
||||
{
|
||||
return state with { SweepSeenUpTo = seenUpTo };
|
||||
}
|
||||
|
||||
report.Pruned += await items
|
||||
.SweepAsync(vaultId, ItemKinds.SyncedTypes, mark, seenUpTo, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return state with { SweepMark = null, SweepSeenUpTo = 0 };
|
||||
}
|
||||
|
||||
private StoredSyncState Record(
|
||||
Guid vaultId,
|
||||
StoredSyncState state,
|
||||
@@ -794,6 +867,26 @@ public sealed class SyncEngine
|
||||
|
||||
if (failure is null)
|
||||
{
|
||||
// An edit of a version this machine once saw means the server had the item and has since let go
|
||||
// of it: deleted elsewhere, and the deletion collected past its retention before this machine
|
||||
// heard of it. The edit is the user's work and goes back, but not silently — somebody deleted
|
||||
// this on purpose. A create has no expected version and is the ordinary case.
|
||||
if (operation.ExpectedVersion is not null)
|
||||
{
|
||||
await conflicts.RecordAsync(
|
||||
vaultId,
|
||||
operation.EntityType,
|
||||
operation.EntityId,
|
||||
ConflictKind.RecreatedAfterCollection,
|
||||
ConflictDetails.Encode(
|
||||
"This was deleted elsewhere while this machine had unsaved changes to it, long enough ago "
|
||||
+ "that the server no longer remembered the deletion. The changes were kept and the item "
|
||||
+ "was put back."),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
report.Recreated++;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,17 @@ public sealed record SyncOptions
|
||||
/// changed anything, which is otherwise the sort of thing that looks like a fault.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="PushedLogEntries">How many of <paramref name="Pushed"/> were log entries.</param>
|
||||
/// <param name="PulledLogEntries">How many of <paramref name="Pulled"/> were log entries.</param>
|
||||
/// <param name="Recreated">
|
||||
/// Items this machine had edited that somebody else had deleted so long ago the server no longer
|
||||
/// remembered it. The edit was kept and the item sent again under its own name — and the user is told,
|
||||
/// because the deletion was somebody's decision.
|
||||
/// </param>
|
||||
/// <param name="Pruned">
|
||||
/// Items removed from this machine because a read from the beginning found the server no longer has them:
|
||||
/// deleted elsewhere while this machine was away for longer than the server keeps deletions.
|
||||
/// </param>
|
||||
public sealed record SyncReport(
|
||||
Guid VaultId,
|
||||
int Pulled,
|
||||
@@ -99,11 +110,13 @@ public sealed record SyncReport(
|
||||
bool RoundsExhausted,
|
||||
bool ResyncedFromStart,
|
||||
int PushedLogEntries = 0,
|
||||
int PulledLogEntries = 0)
|
||||
int PulledLogEntries = 0,
|
||||
int Recreated = 0,
|
||||
int Pruned = 0)
|
||||
{
|
||||
/// <summary>Whether anything happened that a user should be told about.</summary>
|
||||
public bool NeedsAttention =>
|
||||
Resurrected > 0 || DeletesAbandoned > 0 || Parked > 0 || Unreadable > 0 || RekeyRequired;
|
||||
Resurrected > 0 || Recreated > 0 || DeletesAbandoned > 0 || Parked > 0 || Unreadable > 0 || RekeyRequired;
|
||||
|
||||
/// <summary>
|
||||
/// Operations the server accepted that were somebody's own work rather than a log entry.
|
||||
@@ -160,6 +173,10 @@ internal sealed class SyncReportBuilder(Guid vaultId)
|
||||
|
||||
internal bool ResyncedFromStart { get; set; }
|
||||
|
||||
internal int Recreated { get; set; }
|
||||
|
||||
internal int Pruned { get; set; }
|
||||
|
||||
internal SyncReport Build() =>
|
||||
new(
|
||||
vaultId,
|
||||
@@ -176,7 +193,9 @@ internal sealed class SyncReportBuilder(Guid vaultId)
|
||||
RoundsExhausted,
|
||||
ResyncedFromStart,
|
||||
PushedLogEntries,
|
||||
PulledLogEntries);
|
||||
PulledLogEntries,
|
||||
Recreated,
|
||||
Pruned);
|
||||
}
|
||||
|
||||
/// <summary>The record written to the conflict log when a merge had to override something.</summary>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
namespace DodoSSH.Client.Terminal;
|
||||
|
||||
/// <summary>What one session's output has most recently looked like.</summary>
|
||||
/// <param name="Tail">The last bytes the remote wrote, oldest first.</param>
|
||||
/// <param name="AlternateScreen">
|
||||
/// Whether the remote has switched to the alternate screen — vi, less, htop — and not yet switched back.
|
||||
/// </param>
|
||||
public sealed record RecentOutput(byte[] Tail, bool AlternateScreen);
|
||||
|
||||
/// <summary>
|
||||
/// The last kilobyte a session's remote wrote, and whether a full-screen program is running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This exists so that the phone's command history only keeps what the remote echoed.</b> A terminal is
|
||||
/// one input stream, and the client cannot tell a shell prompt from a <c>sudo</c> password prompt by what is
|
||||
/// typed into it — only by whether the characters came back. A password typed with echo off leaves the
|
||||
/// prompt line exactly as it was, and that is the test the history applies before it keeps anything. See
|
||||
/// <c>TypedCommandHistory</c> in the shell.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Kept beside the pump's read loop, and costing that loop almost nothing.</b> Each read copies at most
|
||||
/// <see cref="Capacity"/> bytes into a ring and runs one vectorised search over the read for the
|
||||
/// alternate-screen switches, with no allocation. Locked rather than lock-free
|
||||
/// because it is written from the read loop's thread and read from the UI thread on Enter, and an Enter is
|
||||
/// rare enough that a lock is the honest price.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing here outlives the session or leaves this process. It is the same bytes the renderer is shown,
|
||||
/// held for as long as it takes for the next kilobyte to push them out.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OutputTail
|
||||
{
|
||||
/// <summary>How much output is kept.</summary>
|
||||
/// <remarks>
|
||||
/// A prompt and a long command line with room to spare. A line longer than this is not recorded at all,
|
||||
/// which is the safe direction to fail in.
|
||||
/// </remarks>
|
||||
public const int Capacity = 1024;
|
||||
|
||||
/// <summary>The longest switch sequence, less one: how far a match can straddle two reads.</summary>
|
||||
private const int Overlap = 7;
|
||||
|
||||
private readonly Lock gate = new();
|
||||
private readonly byte[] ring = new byte[Capacity];
|
||||
private int start;
|
||||
private int count;
|
||||
private bool alternateScreen;
|
||||
|
||||
/// <summary>Takes a read's worth of output.</summary>
|
||||
public void Append(ReadOnlySpan<byte> output)
|
||||
{
|
||||
if (output.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
// The seam first — the end of what is already held and the start of the new bytes — so a switch
|
||||
// split across two reads is still seen whole; then the whole read, in which a later switch wins.
|
||||
// All of it rather than its tail: a program that switches screens and then paints sixty
|
||||
// kilobytes in the same read is the ordinary case, not the odd one.
|
||||
var carried = Math.Min(count, Overlap);
|
||||
Span<byte> seam = stackalloc byte[carried + Math.Min(output.Length, Overlap)];
|
||||
CopyLast(seam[..carried]);
|
||||
output[..(seam.Length - carried)].CopyTo(seam[carried..]);
|
||||
|
||||
alternateScreen = ScanForScreenSwitch(output, ScanForScreenSwitch(seam, alternateScreen));
|
||||
|
||||
foreach (var b in output.Length > Capacity ? output[^Capacity..] : output)
|
||||
{
|
||||
ring[(start + count) % Capacity] = b;
|
||||
|
||||
if (count < Capacity)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
else
|
||||
{
|
||||
start = (start + 1) % Capacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A copy of what is held, oldest byte first.</summary>
|
||||
public RecentOutput Snapshot()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var copy = new byte[count];
|
||||
CopyLast(copy);
|
||||
|
||||
return new RecentOutput(copy, alternateScreen);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Copies the last <c>destination.Length</c> held bytes. Called under the lock.</summary>
|
||||
private void CopyLast(Span<byte> destination)
|
||||
{
|
||||
var offset = count - destination.Length;
|
||||
|
||||
for (var i = 0; i < destination.Length; i++)
|
||||
{
|
||||
destination[i] = ring[(start + offset + i) % Capacity];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the screen is alternate after this output, given whether it was before.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The three DEC private modes programs use for it — 1049, which every modern one sends, and the older
|
||||
/// 47 and 1047 — set with <c>h</c> and reset with <c>l</c>. The last switch in the window wins.
|
||||
/// </remarks>
|
||||
private static bool ScanForScreenSwitch(ReadOnlySpan<byte> window, bool current)
|
||||
{
|
||||
ReadOnlySpan<byte> prefix = [0x1B, (byte)'[', (byte)'?'];
|
||||
|
||||
var state = current;
|
||||
var at = 0;
|
||||
|
||||
while (at < window.Length)
|
||||
{
|
||||
var found = window[at..].IndexOf(prefix);
|
||||
|
||||
if (found < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var digits = at + found + prefix.Length;
|
||||
var end = digits;
|
||||
|
||||
while (end < window.Length && window[end] is >= (byte)'0' and <= (byte)'9')
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end < window.Length && window[end] is (byte)'h' or (byte)'l')
|
||||
{
|
||||
var mode = window[digits..end];
|
||||
|
||||
if (mode.SequenceEqual("1049"u8) || mode.SequenceEqual("1047"u8) || mode.SequenceEqual("47"u8))
|
||||
{
|
||||
state = window[end] == (byte)'h';
|
||||
}
|
||||
}
|
||||
|
||||
at = digits;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,10 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
/// <summary>Bytes read from the remote and not yet sent to the renderer.</summary>
|
||||
public int BytesPending => backlog.Buffered;
|
||||
|
||||
/// <summary>The last of what the remote wrote, for the phone's command history.</summary>
|
||||
/// <remarks>See <see cref="OutputTail"/> for why a client keeps any of it.</remarks>
|
||||
public OutputTail Recent { get; } = new();
|
||||
|
||||
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
||||
public long FramesSent { get; private set; }
|
||||
|
||||
@@ -353,6 +357,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
}
|
||||
|
||||
BytesRead += read;
|
||||
Recent.Append(buffer.AsSpan(0, read));
|
||||
|
||||
// Copied out of the rented buffer, because the backlog holds it until the renderer has
|
||||
// room and the next read is already about to overwrite this one.
|
||||
|
||||
@@ -280,6 +280,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What one live session's remote has most recently written, or null when the id names no session this
|
||||
/// workspace still has open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For the phone's command history, which keeps a typed line only once it has seen the remote echo it —
|
||||
/// see <see cref="OutputTail"/>. A copy, so the caller can take its time over it.
|
||||
/// </remarks>
|
||||
public RecentOutput? GetRecentOutput(uint sessionId)
|
||||
{
|
||||
lock (sessionGate)
|
||||
{
|
||||
return sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted
|
||||
? session.Pump.Recent.Snapshot()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A live session's flow-control window, or null when the id names no session this workspace still has
|
||||
/// open.
|
||||
|
||||
@@ -19,10 +19,21 @@ namespace DodoSSH.Domain.Sync;
|
||||
/// The cursor also carries its vault id, so a legitimately-issued cursor for one vault cannot be
|
||||
/// replayed against another. See ADR 0003.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// And the vault's sync horizon when it was issued — the last deletion the server had forgotten. A cursor
|
||||
/// behind the horizon is refused when it is older than the collection, because its holder may still have the
|
||||
/// deleted item; it is fine when it is newer, because then it belongs to a read from the beginning that
|
||||
/// started after the collection, whose early pages are behind the horizon by nature. Position alone cannot
|
||||
/// tell those two apart. A cursor issued under no horizon keeps the first format, so a server from before
|
||||
/// this still reads it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SyncCursor
|
||||
{
|
||||
private const string Version = "v1";
|
||||
|
||||
/// <summary>The format that carries a horizon as well.</summary>
|
||||
private const string HorizonVersion = "v2";
|
||||
private const char Separator = '|';
|
||||
|
||||
/// <summary>Length of the truncated HMAC tag. 128 bits is ample for a non-secret position.</summary>
|
||||
@@ -37,7 +48,17 @@ public static class SyncCursor
|
||||
/// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param>
|
||||
/// <param name="vaultId">Vault the cursor belongs to.</param>
|
||||
/// <param name="sequence">Last change sequence the client has consumed.</param>
|
||||
public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence)
|
||||
public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence) =>
|
||||
Encode(signingKey, vaultId, sequence, horizon: 0);
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a position, with the sync horizon it was read under.
|
||||
/// </summary>
|
||||
/// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param>
|
||||
/// <param name="vaultId">Vault the cursor belongs to.</param>
|
||||
/// <param name="sequence">Last change sequence the client has consumed.</param>
|
||||
/// <param name="horizon">The vault's sync horizon before the read that produced this position.</param>
|
||||
public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence, long horizon)
|
||||
{
|
||||
RequireKey(signingKey);
|
||||
|
||||
@@ -46,7 +67,12 @@ public static class SyncCursor
|
||||
throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must not be negative.");
|
||||
}
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence));
|
||||
if (horizon < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(horizon), horizon, "Horizon must not be negative.");
|
||||
}
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence, horizon));
|
||||
|
||||
var buffer = new byte[payload.Length + TagLength];
|
||||
payload.CopyTo(buffer, 0);
|
||||
@@ -70,10 +96,27 @@ public static class SyncCursor
|
||||
ReadOnlySpan<byte> signingKey,
|
||||
string? cursor,
|
||||
Guid expectedVaultId,
|
||||
out long sequence)
|
||||
out long sequence) =>
|
||||
TryDecode(signingKey, cursor, expectedVaultId, out sequence, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Decodes and verifies a cursor, with the horizon it was issued under — zero for one issued under none.
|
||||
/// </summary>
|
||||
/// <param name="signingKey">Deployment cursor signing key.</param>
|
||||
/// <param name="cursor">The cursor to verify.</param>
|
||||
/// <param name="expectedVaultId">Vault the request is scoped to.</param>
|
||||
/// <param name="sequence">The decoded sequence, when verification succeeds.</param>
|
||||
/// <param name="horizon">The decoded horizon, when verification succeeds.</param>
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> signingKey,
|
||||
string? cursor,
|
||||
Guid expectedVaultId,
|
||||
out long sequence,
|
||||
out long horizon)
|
||||
{
|
||||
RequireKey(signingKey);
|
||||
sequence = 0;
|
||||
horizon = 0;
|
||||
|
||||
if (string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
@@ -97,17 +140,26 @@ public static class SyncCursor
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryParsePayload(payload, expectedVaultId, out sequence);
|
||||
return TryParsePayload(payload, expectedVaultId, out sequence, out horizon);
|
||||
}
|
||||
|
||||
private static string FormatPayload(Guid vaultId, long sequence) =>
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{Version}{Separator}{vaultId:D}{Separator}{sequence}");
|
||||
private static string FormatPayload(Guid vaultId, long sequence, long horizon) =>
|
||||
horizon == 0
|
||||
? string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{Version}{Separator}{vaultId:D}{Separator}{sequence}")
|
||||
: string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{HorizonVersion}{Separator}{vaultId:D}{Separator}{sequence}{Separator}{horizon}");
|
||||
|
||||
private static bool TryParsePayload(ReadOnlySpan<byte> payload, Guid expectedVaultId, out long sequence)
|
||||
private static bool TryParsePayload(
|
||||
ReadOnlySpan<byte> payload,
|
||||
Guid expectedVaultId,
|
||||
out long sequence,
|
||||
out long horizon)
|
||||
{
|
||||
sequence = 0;
|
||||
horizon = 0;
|
||||
|
||||
string text;
|
||||
try
|
||||
@@ -122,12 +174,14 @@ public static class SyncCursor
|
||||
// Tagged, so the shape is ours — but parse defensively anyway, because a key rotation
|
||||
// could make an old format verify against a new expectation.
|
||||
var parts = text.Split(Separator);
|
||||
if (parts.Length != 3)
|
||||
var expectedParts = parts[0] switch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Version => 3,
|
||||
HorizonVersion => 4,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
if (!string.Equals(parts[0], Version, StringComparison.Ordinal))
|
||||
if (parts.Length != expectedParts)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -137,8 +191,13 @@ public static class SyncCursor
|
||||
return false;
|
||||
}
|
||||
|
||||
return long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence)
|
||||
&& sequence >= 0;
|
||||
if (!long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return expectedParts == 3
|
||||
|| long.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out horizon);
|
||||
}
|
||||
|
||||
private static void ComputeTag(
|
||||
|
||||
@@ -52,6 +52,16 @@ public sealed class Vault
|
||||
/// <summary>Soft-delete marker.</summary>
|
||||
public DateTimeOffset? DeletedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The highest change-log sequence whose tombstone has been collected from this vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A cursor below it may have been issued before a deletion this server no longer records, so a pull
|
||||
/// from it is refused and the client starts over — see <c>TombstoneCollector</c> and ADR 0003. Zero until
|
||||
/// anything has been collected.
|
||||
/// </remarks>
|
||||
public long SyncHorizon { get; set; }
|
||||
|
||||
/// <summary>Wrapped vault keys, one per recipient per generation.</summary>
|
||||
public ICollection<VaultKeyGrant> KeyGrants { get; } = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVaultSyncHorizon : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "sync_horizon",
|
||||
schema: "dodo",
|
||||
table: "vault",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "sync_horizon",
|
||||
schema: "dodo",
|
||||
table: "vault");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -750,6 +750,10 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("rekey_required");
|
||||
|
||||
b.Property<long>("SyncHorizon")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("sync_horizon");
|
||||
|
||||
b.Property<Guid?>("TeamId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("team_id");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using DodoSSH.Api.Features.Sync;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
@@ -612,6 +613,209 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
tombstone.PlaintextFields.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Tombstone collection (ADR 0003) ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A tombstone past its retention goes, with its change-log entries — and a client whose cursor is from
|
||||
/// before it is refused, because it would otherwise never hear of the deletion. A client already past it
|
||||
/// carries on, and a pull from nothing simply no longer offers the item.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The retention is crossed by moving the deletion back in time rather than by waiting it out.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnExpiredTombstone_IsCollected_AndACursorFromBeforeIt_IsRefused()
|
||||
{
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
|
||||
var create = NewCreateBatch();
|
||||
var entityId = create.Operations[0].EntityId;
|
||||
await client.PostContractAsync(PushUrl(vaultId), create);
|
||||
|
||||
var before = await PullAsync(client, vaultId, null);
|
||||
|
||||
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
||||
[
|
||||
new SyncPushOperation(
|
||||
Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete,
|
||||
ExpectedVersion: 1, Payload: null, PlaintextFields: null),
|
||||
]));
|
||||
|
||||
var after = await PullAsync(client, vaultId, before.NextCursor);
|
||||
|
||||
await AgeTheDeletionAsync(entityId);
|
||||
await CollectAsync();
|
||||
|
||||
var refused = await client.PostContractAsync(PullUrl(vaultId), new SyncPullRequest(before.NextCursor, null, null));
|
||||
refused.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await refused.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.InvalidCursor);
|
||||
|
||||
(await PullAsync(client, vaultId, after.NextCursor)).Changes.ShouldBeEmpty("a cursor past the deletion is fine");
|
||||
|
||||
(await PullAsync(client, vaultId, null)).Changes.ShouldNotContain(change => change.EntityId == entityId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The client's way back after a refusal is to read from the beginning — and that read's own cursors are
|
||||
/// behind the horizon whenever an item older than the collected deletion is still there, which is nearly
|
||||
/// always. Refused on position alone, the second page sent the read back to the start, every time, for
|
||||
/// good. The cursor carries the horizon it was issued under, and one issued since the collection passes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One item a page, so the first page's cursor is the old item's position, well behind the horizon.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AReadFromTheBeginning_AfterACollection_IsNotRefusedOnItsOwnPages()
|
||||
{
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
|
||||
var old = NewCreateBatch();
|
||||
await client.PostContractAsync(PushUrl(vaultId), old);
|
||||
await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch());
|
||||
|
||||
var doomed = NewCreateBatch();
|
||||
var entityId = doomed.Operations[0].EntityId;
|
||||
await client.PostContractAsync(PushUrl(vaultId), doomed);
|
||||
|
||||
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
||||
[
|
||||
new SyncPushOperation(
|
||||
Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete,
|
||||
ExpectedVersion: 1, Payload: null, PlaintextFields: null),
|
||||
]));
|
||||
|
||||
await AgeTheDeletionAsync(entityId);
|
||||
await CollectAsync();
|
||||
|
||||
var seen = new List<Guid>();
|
||||
string? cursor = null;
|
||||
|
||||
for (var page = 0; page < 5; page++)
|
||||
{
|
||||
var response = await client.PostContractAsync(PullUrl(vaultId), new SyncPullRequest(cursor, 1, null));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK, $"page {page} was refused");
|
||||
|
||||
var body = (await response.Content.ReadContractAsync<SyncPullResponse>()).ShouldNotBeNull();
|
||||
seen.AddRange(body.Changes.Select(change => change.EntityId));
|
||||
cursor = body.NextCursor;
|
||||
|
||||
if (!body.HasMore)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
seen.Count.ShouldBe(2);
|
||||
seen.ShouldContain(old.Operations[0].EntityId);
|
||||
seen.ShouldNotContain(entityId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the retention: a deletion younger than it stays, entries and all, so a machine that
|
||||
/// has been away a week still hears of it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATombstoneInsideItsRetention_IsKept()
|
||||
{
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
|
||||
var create = NewCreateBatch();
|
||||
var entityId = create.Operations[0].EntityId;
|
||||
await client.PostContractAsync(PushUrl(vaultId), create);
|
||||
|
||||
var before = await PullAsync(client, vaultId, null);
|
||||
|
||||
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
||||
[
|
||||
new SyncPushOperation(
|
||||
Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete,
|
||||
ExpectedVersion: 1, Payload: null, PlaintextFields: null),
|
||||
]));
|
||||
|
||||
await CollectAsync();
|
||||
|
||||
var later = await PullAsync(client, vaultId, before.NextCursor);
|
||||
|
||||
later.Changes.ShouldHaveSingleItem().Operation.ShouldBe(SyncOperation.Delete);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An item edited many times kept an entry per edit for ever, and a client starting from nothing walked
|
||||
/// every one of them. After a collection only the newest is left — and a client that held a cursor from
|
||||
/// between the edits still gets the item, because what it had not seen yet is exactly that newest entry.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SupersededEntries_AreCompactedAway_WithoutCostingAnyClientTheItem()
|
||||
{
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
|
||||
var create = NewCreateBatch();
|
||||
var entityId = create.Operations[0].EntityId;
|
||||
await client.PostContractAsync(PushUrl(vaultId), create);
|
||||
|
||||
var between = await PullAsync(client, vaultId, null);
|
||||
|
||||
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
||||
[
|
||||
NewOperation(entityId, expectedVersion: 1, envelope: [2, 2, 2]),
|
||||
]));
|
||||
|
||||
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
||||
[
|
||||
NewOperation(entityId, expectedVersion: 2, envelope: [3, 3, 3]),
|
||||
]));
|
||||
|
||||
await CollectAsync();
|
||||
|
||||
await using (var scope = fixture.CreateScope())
|
||||
{
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
|
||||
(await database.VaultChanges.CountAsync(c => c.EntityId == entityId, TestContext.Current.CancellationToken))
|
||||
.ShouldBe(1);
|
||||
}
|
||||
|
||||
(await PullAsync(client, vaultId, between.NextCursor)).Changes.ShouldHaveSingleItem().Version.ShouldBe(3);
|
||||
(await PullAsync(client, vaultId, null)).Changes.Single(c => c.EntityId == entityId).Version.ShouldBe(3);
|
||||
}
|
||||
|
||||
private static async Task<SyncPullResponse> PullAsync(HttpClient client, Guid vaultId, string? cursor)
|
||||
{
|
||||
var response = await client.PostContractAsync(PullUrl(vaultId), new SyncPullRequest(cursor, null, null));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
return (await response.Content.ReadContractAsync<SyncPullResponse>()).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Moves a deletion back past the retention, which is quicker than waiting ninety days.</summary>
|
||||
private async Task AgeTheDeletionAsync(Guid entityId)
|
||||
{
|
||||
await using var scope = fixture.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
|
||||
var host = await database.Hosts.SingleAsync(h => h.Id == entityId, TestContext.Current.CancellationToken);
|
||||
host.DeletedAtUtc = host.DeletedAtUtc!.Value.AddDays(-91);
|
||||
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
private async Task CollectAsync()
|
||||
{
|
||||
await using var scope = fixture.CreateScope();
|
||||
|
||||
await scope.ServiceProvider.GetRequiredService<TombstoneCollector>().RunOnceAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
// ---- Batch limits ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -491,7 +491,7 @@
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
||||
"Microsoft.TSS": "[2.1.1, )",
|
||||
"Velopack": "[1.2.0, )"
|
||||
"Velopack": "[1.2.161, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.api": {
|
||||
@@ -788,9 +788,9 @@
|
||||
},
|
||||
"Velopack": {
|
||||
"type": "CentralTransitive",
|
||||
"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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,28 +15,39 @@ public sealed class ProbedDeviceKeyStoreTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <remarks>
|
||||
/// "Off the caller's thread" is proved by blocking, not by comparing thread ids. The probe waits for a
|
||||
/// release the test only gives once construction has returned, so a probe run on the thread that
|
||||
/// constructed the store could never be released and times out instead. Thread ids proved nothing: the
|
||||
/// test itself runs on a pool thread, and that same thread is free to pick up the queued probe the
|
||||
/// moment the test awaits, which is what failed on CI with the code doing exactly the right thing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheChoiceIsMadeOnce_OffTheCallersThread()
|
||||
{
|
||||
var asking = Environment.CurrentManagedThreadId;
|
||||
var probes = 0;
|
||||
var probedOn = 0;
|
||||
var releasedInTime = false;
|
||||
|
||||
using var release = new ManualResetEventSlim();
|
||||
var inner = Substitute.For<IDeviceKeyStore>();
|
||||
|
||||
var store = new ProbedDeviceKeyStore(() =>
|
||||
{
|
||||
Interlocked.Increment(ref probes);
|
||||
probedOn = Environment.CurrentManagedThreadId;
|
||||
releasedInTime = release.Wait(TimeSpan.FromSeconds(10));
|
||||
return inner;
|
||||
});
|
||||
|
||||
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
|
||||
var available = store.IsAvailableAsync(Token);
|
||||
available.IsCompleted.ShouldBeFalse("nothing has released the probe yet");
|
||||
release.Set();
|
||||
|
||||
(await available).ShouldBeTrue();
|
||||
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
|
||||
await store.TryLoadAsync(Token);
|
||||
|
||||
releasedInTime.ShouldBeTrue("the probe ran on the thread that asked, so nothing could release it");
|
||||
probes.ShouldBe(1);
|
||||
probedOn.ShouldNotBe(asking);
|
||||
|
||||
// Availability is the choice, not a second probe through the store it chose.
|
||||
await inner.DidNotReceive().IsAvailableAsync(Arg.Any<CancellationToken>());
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
using System.Text;
|
||||
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Shell.Keyboard;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Terminal;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The phone's terminal keys: what each sends, what a latch does to the next one, the cursor gesture's
|
||||
/// speeds, the history's refusal to keep what was not echoed, and the arrangement surviving a relaunch.
|
||||
/// </summary>
|
||||
public sealed class TerminalKeyboardTests : IDisposable
|
||||
{
|
||||
private readonly string directory =
|
||||
Path.Combine(Path.GetTempPath(), $"dodossh-keys-{Guid.CreateVersion7():N}");
|
||||
|
||||
private readonly List<byte[]> sent = [];
|
||||
|
||||
/// <summary>What the remote has drawn so far, as the history's echo test reads it.</summary>
|
||||
private string screen = "stan@box:~$ ";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text);
|
||||
|
||||
private static string Shown(byte[] bytes) => Encoding.ASCII.GetString(bytes);
|
||||
|
||||
// ── notation ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"\e:wq\r", "\x1b:wq\r")]
|
||||
[InlineData("^C", "\x03")]
|
||||
[InlineData("^c", "\x03")]
|
||||
[InlineData("^[", "\x1b")]
|
||||
[InlineData("^?", "\x7f")]
|
||||
[InlineData(@"\x1b[A", "\x1b[A")]
|
||||
[InlineData(@"\^C", "^C")]
|
||||
[InlineData("^1", "^1")]
|
||||
[InlineData("git status", "git status")]
|
||||
[InlineData(@"a\\b", @"a\b")]
|
||||
public void Notation_IsReadTheWayATerminalUserWritesIt(string notation, string expected)
|
||||
{
|
||||
KeySequence.TryParse(notation, out var bytes).ShouldBeTrue();
|
||||
|
||||
Shown(bytes).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"oops\")]
|
||||
[InlineData(@"\x1")]
|
||||
[InlineData(@"\xZZ")]
|
||||
public void MalformedNotation_IsRefusedRatherThanGuessedAt(string notation) =>
|
||||
KeySequence.TryParse(notation, out _).ShouldBeFalse();
|
||||
|
||||
// ── latches ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("c", true, false, "\x03")]
|
||||
[InlineData("C", true, false, "\x03")]
|
||||
[InlineData(" ", true, false, "\0")]
|
||||
[InlineData("b", false, true, "\u001bb")]
|
||||
[InlineData("\x1b[D", true, false, "\x1b[1;5D")]
|
||||
[InlineData("\x1b[C", true, true, "\x1b[1;7C")]
|
||||
[InlineData("\x1b[A", false, true, "\x1b[1;3A")]
|
||||
[InlineData("ls", true, false, "ls")]
|
||||
[InlineData("\x1b[15~", true, false, "\x1b[15~")]
|
||||
public void ALatchedModifier_EncodesTheNextKeyTheWayXtermDoes(string key, bool control, bool alt, string expected) =>
|
||||
Shown(TerminalModifiers.Apply(Ascii(key), control, alt)).ShouldBe(expected);
|
||||
|
||||
// ── the cursor gesture ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ADragInsideTheDeadZone_SendsNothing()
|
||||
{
|
||||
var gesture = new ArrowGesture();
|
||||
|
||||
gesture.Move(10, -8, 0).ShouldBe(0);
|
||||
gesture.Advance(2_000).ShouldBe(0);
|
||||
gesture.Direction.ShouldBe(ArrowDirection.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeavingTheDeadZone_SendsOneKeyAtOnce_AndTheSlowSpeedWaitsBeforeRepeating()
|
||||
{
|
||||
var gesture = new ArrowGesture();
|
||||
|
||||
gesture.Move(-30, 0, 1_000).ShouldBe(1);
|
||||
gesture.Direction.ShouldBe(ArrowDirection.Left);
|
||||
gesture.Speed.ShouldBe(1);
|
||||
|
||||
// Held: nothing more until the first-repeat delay is up, then one per interval.
|
||||
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs - 1).ShouldBe(0);
|
||||
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs).ShouldBe(1);
|
||||
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs + ArrowGesture.IntervalMs(1)).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DraggingFurtherTheSameWay_ShiftsThroughTheThreeSpeeds()
|
||||
{
|
||||
var gesture = new ArrowGesture();
|
||||
|
||||
gesture.Move(0, -30, 0);
|
||||
gesture.Speed.ShouldBe(1);
|
||||
gesture.Direction.ShouldBe(ArrowDirection.Up);
|
||||
|
||||
gesture.Move(0, -ArrowGesture.SecondSpeedAt, 10);
|
||||
gesture.Speed.ShouldBe(2);
|
||||
|
||||
gesture.Move(0, -ArrowGesture.ThirdSpeedAt, 20);
|
||||
gesture.Speed.ShouldBe(3);
|
||||
|
||||
// A second at the fastest speed is many keys; the same second at the slowest is a handful.
|
||||
var fast = 0;
|
||||
|
||||
for (var t = 20L; t <= 1_020; t += 10)
|
||||
{
|
||||
fast += gesture.Advance(t);
|
||||
}
|
||||
|
||||
var slow = new ArrowGesture();
|
||||
var slowCount = slow.Move(0, -30, 0);
|
||||
|
||||
for (var t = 0L; t <= 1_000; t += 10)
|
||||
{
|
||||
slowCount += slow.Advance(t);
|
||||
}
|
||||
|
||||
fast.ShouldBeGreaterThan(slowCount * 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASmallDriftOffTheAxis_DoesNotTurnLeftIntoUp()
|
||||
{
|
||||
var gesture = new ArrowGesture();
|
||||
|
||||
gesture.Move(-40, 0, 0);
|
||||
gesture.Move(-40, -45, 10);
|
||||
|
||||
gesture.Direction.ShouldBe(ArrowDirection.Left);
|
||||
|
||||
// Clearly vertical now, past the hysteresis.
|
||||
gesture.Move(-20, -60, 20).ShouldBe(1);
|
||||
gesture.Direction.ShouldBe(ArrowDirection.Up);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComingBackToTheCentre_Stops()
|
||||
{
|
||||
var gesture = new ArrowGesture();
|
||||
|
||||
gesture.Move(50, 0, 0);
|
||||
gesture.Move(3, 2, 10).ShouldBe(0);
|
||||
|
||||
gesture.Direction.ShouldBe(ArrowDirection.None);
|
||||
gesture.Advance(5_000).ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── reading back the echo ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("\x1b[32mstan@box\x1b[0m:~$ ls -la", "stan@box:~$ ls -la")]
|
||||
[InlineData("old output\r\n$ git statsu\b \b\b \bus", "$ git status")]
|
||||
[InlineData("$ rm -rf build\r$ ls\x1b[K", "$ ls")]
|
||||
[InlineData("\x1b]0;stan@box: ~\a$ uptime", "$ uptime")]
|
||||
[InlineData("$ ech\x1b[1D\x1b[1@o", "$ ecoh")]
|
||||
public void TheCursorsLine_IsReadAsATerminalWouldDrawIt(string output, string expected) =>
|
||||
EchoedLine.LastLine(Ascii(output)).ShouldBe(expected);
|
||||
|
||||
// ── the history ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private RecentOutput Recent() => new(Ascii(screen), AlternateScreen: false);
|
||||
|
||||
/// <summary>Types text one key at a time, echoing each key the way an ordinary shell prompt does.</summary>
|
||||
private void TypeEchoed(TypedCommandHistory history, string text)
|
||||
{
|
||||
foreach (var c in text)
|
||||
{
|
||||
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||
screen += c;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACommandTheShellEchoed_IsKept()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
|
||||
TypeEchoed(history, "uptime");
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldHaveSingleItem().ShouldBe(new HistoryEntry("uptime", "box"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APasswordTypedWithEchoOff_IsNeverKept()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
screen = "[sudo] password for stan: ";
|
||||
|
||||
// Typed, but nothing comes back: the prompt line is exactly what it was.
|
||||
foreach (var c in "hunter2")
|
||||
{
|
||||
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||
}
|
||||
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APasswordTypedAfterAnArrowKey_IsStillNeverKept()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
screen = "Password: ";
|
||||
|
||||
history.Observe(1, "box", "\x1b[D"u8, Recent);
|
||||
|
||||
foreach (var c in "s3cret")
|
||||
{
|
||||
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||
}
|
||||
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APromptThatEchoesStars_IsNotACommand()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
screen = "Passphrase: ";
|
||||
|
||||
foreach (var c in "s3cret")
|
||||
{
|
||||
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||
screen += '*';
|
||||
}
|
||||
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALineFinishedWithTab_IsKeptAsTheShellCompletedIt()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
|
||||
TypeEchoed(history, "cd /var/lo");
|
||||
history.Observe(1, "box", "\t"u8, Recent);
|
||||
screen += "g/";
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldHaveSingleItem().Command.ShouldBe("cd /var/log/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingTypedIntoAFullScreenProgram_IsKept()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
|
||||
RecentOutput InVi() => new(Ascii("~\r\n~\r\nhello"), AlternateScreen: true);
|
||||
|
||||
foreach (var c in "hello")
|
||||
{
|
||||
history.Observe(1, "box", Ascii(c.ToString()), InVi);
|
||||
}
|
||||
|
||||
history.Observe(1, "box", "\r"u8, InVi);
|
||||
|
||||
history.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunningACommandAgain_MovesItToTheTopRatherThanListingItTwice()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
|
||||
foreach (var command in new[] { "ls", "df -h", "ls" })
|
||||
{
|
||||
screen = "$ ";
|
||||
TypeEchoed(history, command);
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
}
|
||||
|
||||
history.Entries.Select(e => e.Command).ShouldBe(["ls", "df -h"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CtrlC_AbandonsTheLine()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
|
||||
TypeEchoed(history, "rm -rf /tmp/x");
|
||||
history.Observe(1, "box", "\x03"u8, Recent);
|
||||
screen = "stan@box:~$ ";
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ── the view model ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private ClientSettingsStore Settings() => new(new ClientPaths(directory));
|
||||
|
||||
private TerminalKeyboardViewModel Keyboard(ClientSettingsStore? store = null) =>
|
||||
new(
|
||||
store ?? Settings(),
|
||||
() => new TypingTarget(1, "box"),
|
||||
(_, bytes) =>
|
||||
{
|
||||
sent.Add(bytes.ToArray());
|
||||
return ValueTask.CompletedTask;
|
||||
},
|
||||
_ => Recent(),
|
||||
(_, _, _, _) => Task.FromResult(true),
|
||||
_ => Task.CompletedTask);
|
||||
|
||||
private static KeyButtonViewModel Button(TerminalKeyboardViewModel keyboard, string id) =>
|
||||
keyboard.Groups.SelectMany(g => g.Keys).First(k => k.Key.Id == id);
|
||||
|
||||
[Fact]
|
||||
public void CtrlOnTheBar_AppliesToTheNextLetterFromTheSoftKeyboard_AndThenLetsGo()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
Button(keyboard, "ctrl").PressCommand.Execute(null);
|
||||
keyboard.IsControlLatched.ShouldBeTrue();
|
||||
Button(keyboard, "ctrl").IsLatched.ShouldBeTrue();
|
||||
|
||||
keyboard.Send(Ascii("c"));
|
||||
keyboard.Send(Ascii("c"));
|
||||
|
||||
sent.Select(Shown).ShouldBe(["\x03", "c"]);
|
||||
keyboard.IsControlLatched.ShouldBeFalse();
|
||||
Button(keyboard, "ctrl").IsLatched.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheBarAsShipped_IsTwoRows_ModifiersAboveCursorKeys()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
keyboard.BarRows.Count.ShouldBe(2);
|
||||
keyboard.BarRows[0].Keys.Select(k => k.Label).ShouldBe(["esc", "tab", "ctrl", "alt", "|", "-", "/", "~"]);
|
||||
keyboard.BarRows[1].Keys.Select(k => k.Label).ShouldBe(["←", "↓", "↑", "→", "paste"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACustomisedBar_SurvivesARelaunch()
|
||||
{
|
||||
var store = Settings();
|
||||
var keyboard = Keyboard(store);
|
||||
|
||||
keyboard.OpenCustomizeCommand.Execute(null);
|
||||
keyboard.BarEditor[1].MoveUpCommand.Execute(null); // tab before esc
|
||||
keyboard.BarEditor.Single(r => r.Label == "paste").RemoveCommand.Execute(null);
|
||||
keyboard.NewKeyLabel = "wq";
|
||||
keyboard.NewKeySequence = @"\e:wq\r";
|
||||
keyboard.AddCustomKeyCommand.Execute(null);
|
||||
|
||||
var relaunched = Keyboard(store);
|
||||
var labels = relaunched.BarRows.SelectMany(r => r.Keys).Select(k => k.Label).ToList();
|
||||
|
||||
labels[0].ShouldBe("tab");
|
||||
labels[1].ShouldBe("esc");
|
||||
labels.ShouldNotContain("paste");
|
||||
labels[^1].ShouldBe("wq");
|
||||
relaunched.Groups.Last().Title.ShouldBe("YOUR KEYS");
|
||||
|
||||
relaunched.BarRows.SelectMany(r => r.Keys).Last().PressCommand.Execute(null);
|
||||
Shown(sent.Last()).ShouldBe("\x1b:wq\r");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHiddenGroup_IsLeftOffTheKeysTab_AndStaysHidden()
|
||||
{
|
||||
var store = Settings();
|
||||
var keyboard = Keyboard(store);
|
||||
|
||||
keyboard.GroupEditor.Single(g => g.Title == "TMUX").IsShown = false;
|
||||
|
||||
keyboard.Groups.Select(g => g.Title).ShouldNotContain("TMUX");
|
||||
Keyboard(store).Groups.Select(g => g.Title).ShouldNotContain("TMUX");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_PutsTheBarBack_ButKeepsKeysSomebodyWrote()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
keyboard.NewKeyLabel = "deploy";
|
||||
keyboard.NewKeySequence = "make deploy";
|
||||
keyboard.AddCustomKeyCommand.Execute(null);
|
||||
|
||||
keyboard.ResetLayoutCommand.Execute(null);
|
||||
|
||||
keyboard.BarRows.SelectMany(r => r.Keys).Select(k => k.Key.Id).ShouldBe(TerminalKeyCatalog.DefaultBar);
|
||||
keyboard.CustomKeyEditor.ShouldHaveSingleItem().Label.ShouldBe("deploy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AKeyWithANonsenseSequence_IsRefusedWithAReason()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
keyboard.NewKeyLabel = "bad";
|
||||
keyboard.NewKeySequence = @"\x1";
|
||||
keyboard.AddCustomKeyCommand.Execute(null);
|
||||
|
||||
keyboard.CustomKeyEditor.ShouldBeEmpty();
|
||||
keyboard.CustomizeStatus.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheBarHoldsSixteenKeys_AndSaysSoWhenFull()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
foreach (var choice in keyboard.GroupEditor.Single(g => g.Title == "FUNCTION").Keys.ToList())
|
||||
{
|
||||
// Rebuilt after every toggle, so look the chip up again each time.
|
||||
keyboard.GroupEditor.Single(g => g.Title == "FUNCTION").Keys
|
||||
.Single(k => k.Label == choice.Label).ToggleCommand.Execute(null);
|
||||
}
|
||||
|
||||
keyboard.BarRows.Sum(r => r.Keys.Count).ShouldBe(TerminalKeyCatalog.MaximumBarKeys);
|
||||
keyboard.CanAddToBar.ShouldBeFalse();
|
||||
keyboard.CustomizeStatus.ShouldContain("16");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheQuickAccessKeyboard_ReopensToTheTabLastUsed()
|
||||
{
|
||||
var store = Settings();
|
||||
|
||||
Keyboard(store).SelectTabCommand.Execute(QuickAccessTab.History);
|
||||
|
||||
var relaunched = Keyboard(store);
|
||||
|
||||
relaunched.SelectedTab.ShouldBe(QuickAccessTab.History);
|
||||
relaunched.IsHistoryTab.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnIdThisBuildDoesNotKnow_IsSkippedRatherThanRefused()
|
||||
{
|
||||
var store = Settings();
|
||||
store.Write(new ClientSettings(TerminalKeyboard: new TerminalKeyboardSettings(
|
||||
Bar: ["esc", "from-the-future", "up"],
|
||||
GroupOrder: ["vim", "nonsense"])));
|
||||
|
||||
var keyboard = Keyboard(store);
|
||||
|
||||
keyboard.BarRows.ShouldHaveSingleItem().Keys.Select(k => k.Key.Id).ShouldBe(["esc", "up"]);
|
||||
keyboard.Groups[0].Title.ShouldBe("VIM");
|
||||
keyboard.Groups.Count.ShouldBe(TerminalKeyCatalog.Groups.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheGesturesArrows_IgnoreALatchedCtrl()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
Button(keyboard, "ctrl").PressCommand.Execute(null);
|
||||
keyboard.SendArrow(ArrowDirection.Up, 2);
|
||||
|
||||
Shown(sent.Single()).ShouldBe("\x1b[A\x1b[A");
|
||||
keyboard.IsControlLatched.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -480,7 +480,7 @@
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
||||
"Microsoft.TSS": "[2.1.1, )",
|
||||
"Velopack": "[1.2.0, )"
|
||||
"Velopack": "[1.2.161, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.api": {
|
||||
@@ -777,9 +777,9 @@
|
||||
},
|
||||
"Velopack": {
|
||||
"type": "CentralTransitive",
|
||||
"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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
SyncPullRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var issuedUnder = horizon;
|
||||
var after = DecodeCursor(request.Cursor);
|
||||
var limit = Math.Clamp(request.Limit ?? MaxPullLimit, 1, MaxPullLimit);
|
||||
|
||||
@@ -128,7 +129,7 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
|
||||
return Task.FromResult(new SyncPullResponse(
|
||||
[.. page.Select(entry => Hydrate(entry))],
|
||||
EncodeCursor(next),
|
||||
EncodeCursor(next, issuedUnder),
|
||||
hasMore,
|
||||
Now,
|
||||
KeyGeneration));
|
||||
@@ -206,7 +207,25 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
internal Row? Find(Guid entityId, SyncEntityType entityType = SyncEntityType.Host) =>
|
||||
rows.TryGetValue((entityType, entityId), out var row) ? row : null;
|
||||
|
||||
private long Head => log.Count == 0 ? 0 : log[^1].Sequence;
|
||||
/// <summary>
|
||||
/// Forgets every deletion, as the real server does once one is past its retention: the tombstone and
|
||||
/// its log entries go, and a cursor from before them is refused from then on.
|
||||
/// </summary>
|
||||
internal void CollectTombstones()
|
||||
{
|
||||
foreach (var (key, row) in rows.Where(entry => entry.Value.IsDeleted).ToList())
|
||||
{
|
||||
rows.Remove(key);
|
||||
log.RemoveAll(entry => entry.EntityType == key.Type && entry.EntityId == key.EntityId);
|
||||
horizon = Math.Max(horizon, row.ChangeSequence);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The newest sequence ever issued, which a collection must not take back.</summary>
|
||||
private long Head => Math.Max(log.Count == 0 ? 0 : log[^1].Sequence, horizon);
|
||||
|
||||
/// <summary>The last deletion forgotten; see <see cref="CollectTombstones"/>.</summary>
|
||||
private long horizon;
|
||||
|
||||
// ---- The decision table ----
|
||||
|
||||
@@ -481,8 +500,12 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
/// rejects. The real server HMAC-tags them; the property that matters to the client is only that it
|
||||
/// must round-trip what it is given.
|
||||
/// </remarks>
|
||||
private static string EncodeCursor(long sequence) =>
|
||||
"fake-v1:" + sequence.ToString(CultureInfo.InvariantCulture);
|
||||
/// <remarks>
|
||||
/// Carries the horizon it was read under, as the real one does, so a read from the beginning that started
|
||||
/// after a collection is not refused on its own early pages. See <c>DodoSSH.Domain.Sync.SyncCursor</c>.
|
||||
/// </remarks>
|
||||
private static string EncodeCursor(long sequence, long issuedUnder = 0) =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"fake-v1:{sequence}@{issuedUnder}");
|
||||
|
||||
private long DecodeCursor(string? cursor)
|
||||
{
|
||||
@@ -502,12 +525,26 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
return 0;
|
||||
}
|
||||
|
||||
var at = cursor.IndexOf('@', StringComparison.Ordinal);
|
||||
|
||||
if (!cursor.StartsWith("fake-v1:", StringComparison.Ordinal)
|
||||
|| !long.TryParse(cursor.AsSpan(8), CultureInfo.InvariantCulture, out var sequence))
|
||||
|| at < 0
|
||||
|| !long.TryParse(cursor.AsSpan(8, at - 8), CultureInfo.InvariantCulture, out var sequence)
|
||||
|| !long.TryParse(cursor.AsSpan(at + 1), CultureInfo.InvariantCulture, out var issuedUnder))
|
||||
{
|
||||
throw new InvalidOperationException($"A client sent a cursor it should not have: '{cursor}'.");
|
||||
}
|
||||
|
||||
if (sequence < horizon && issuedUnder < horizon)
|
||||
{
|
||||
// From before a deletion this server no longer remembers, so it could never be told of it.
|
||||
throw new DodoSshApiException(
|
||||
HttpStatusCode.BadRequest,
|
||||
ProblemCodes.InvalidCursor,
|
||||
"The server returned 400: The sync cursor is not valid for this vault. "
|
||||
+ "Resync from the beginning.");
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using DodoSSH.Client.Storage;
|
||||
using static DodoSSH.Client.Sync.Tests.SyncHarness;
|
||||
|
||||
namespace DodoSSH.Client.Sync.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A machine away for longer than the server keeps deletions (ADR 0003).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The server forgets a deletion once it is past its retention and refuses any cursor from before it. The
|
||||
/// machine then reads everything again — which says nothing about what is gone — so what it held and did
|
||||
/// not read again is what has to go. Except the user's own unsent work, which is kept and said out loud.
|
||||
/// </remarks>
|
||||
public sealed class TombstoneCollectionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AnItemDeletedAndForgottenWhileAway_IsRemovedByTheReadFromTheBeginning()
|
||||
{
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
var gone = await harness.Second.CreateAsync(Host("old-db"));
|
||||
var kept = await harness.Second.CreateAsync(Host("prod-db"));
|
||||
await harness.Second.SyncAsync();
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
await harness.Second.DeleteAsync(gone);
|
||||
await harness.Second.SyncAsync();
|
||||
harness.Server.CollectTombstones();
|
||||
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.ResyncedFromStart.ShouldBeTrue();
|
||||
report.Pruned.ShouldBe(1);
|
||||
(await harness.First.ListAsync()).Items.Select(item => item.EntityId).ShouldBe([kept]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheOnlyItemInAVault_IsRemovedAsWell()
|
||||
{
|
||||
// The read from the beginning brings back nothing at all, so a sweep bounded by what it read would
|
||||
// reach nothing either. It is bounded by what the cache held when it started.
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
var gone = await harness.Second.CreateAsync(Host("old-db"));
|
||||
await harness.Second.SyncAsync();
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
await harness.Second.DeleteAsync(gone);
|
||||
await harness.Second.SyncAsync();
|
||||
harness.Server.CollectTombstones();
|
||||
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.Pruned.ShouldBe(1);
|
||||
(await harness.First.ListAsync()).Items.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnItemWithAnUnsentEdit_IsKept_PutBack_AndSaidSo()
|
||||
{
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
var entityId = await harness.Second.CreateAsync(Host("old-db"));
|
||||
await harness.Second.SyncAsync();
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
await harness.First.UpdateAsync(entityId, Host("old-db, still needed"));
|
||||
|
||||
await harness.Second.DeleteAsync(entityId);
|
||||
await harness.Second.SyncAsync();
|
||||
harness.Server.CollectTombstones();
|
||||
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.Pruned.ShouldBe(0);
|
||||
report.Recreated.ShouldBe(1);
|
||||
report.NeedsAttention.ShouldBeTrue();
|
||||
|
||||
(await harness.First.FindAsync(entityId)).Secret.Label.ShouldBe("old-db, still needed");
|
||||
harness.Server.Find(entityId).ShouldNotBeNull().IsDeleted.ShouldBeFalse();
|
||||
|
||||
(await harness.First.ConflictsAsync())
|
||||
.ShouldContain(conflict => conflict.EntityId == entityId
|
||||
&& conflict.Kind == ConflictKind.RecreatedAfterCollection);
|
||||
|
||||
await harness.Second.SyncAsync();
|
||||
(await harness.Second.FindAsync(entityId)).Secret.Label.ShouldBe("old-db, still needed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AReadSpreadOverSeveralPasses_SweepsAtTheEnd_AndKeepsWhatWasPushedMeanwhile()
|
||||
{
|
||||
// Pages per pass are capped, so the read from the beginning can take more than one pass, and what it
|
||||
// has seen has to outlast each one. A host made here during it is pushed between the passes.
|
||||
using var harness = await CreateAsync(new SyncOptions { PullPageSize = 1, MaxPullPages = 2 });
|
||||
|
||||
harness.Server.MaxPullLimit = 1;
|
||||
|
||||
var gone = await harness.Second.CreateAsync(Host("old-db"));
|
||||
var kept = new List<Guid>();
|
||||
|
||||
for (var index = 0; index < 4; index++)
|
||||
{
|
||||
kept.Add(await harness.Second.CreateAsync(Host($"host-{index}")));
|
||||
}
|
||||
|
||||
// Settled by hand: one pass reads at most two pages, and each needs several.
|
||||
for (var pass = 0; pass < 6; pass++)
|
||||
{
|
||||
await harness.Second.SyncAsync();
|
||||
await harness.First.SyncAsync();
|
||||
}
|
||||
|
||||
await harness.Second.DeleteAsync(gone);
|
||||
await harness.Second.SyncAsync();
|
||||
harness.Server.CollectTombstones();
|
||||
|
||||
kept.Add(await harness.First.CreateAsync(Host("made-meanwhile")));
|
||||
|
||||
var pruned = 0;
|
||||
|
||||
for (var pass = 0; pass < 10; pass++)
|
||||
{
|
||||
pruned += (await harness.First.SyncAsync()).Pruned;
|
||||
|
||||
var state = await harness.First.SyncState.ReadAsync(VaultId, TestContext.Current.CancellationToken);
|
||||
|
||||
if (state.SweepMark is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Mid-sweep, nothing has gone yet.
|
||||
pruned.ShouldBe(0);
|
||||
(await harness.First.ListAsync()).Items.Count.ShouldBe(6);
|
||||
}
|
||||
|
||||
pruned.ShouldBe(1);
|
||||
(await harness.First.ListAsync()).Items.Select(item => item.EntityId).ShouldBe(kept, ignoreOrder: true);
|
||||
harness.Server.Find(kept[^1]).ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// That a session keeps the last of its output for the phone's history to read back, and knows when a
|
||||
/// full-screen program has it.
|
||||
/// </summary>
|
||||
public sealed class OutputTailTests
|
||||
{
|
||||
private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text);
|
||||
|
||||
[Fact]
|
||||
public void WhatIsHeld_IsTheLastKilobyte_InOrder()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
tail.Append(Ascii(new string('a', OutputTail.Capacity)));
|
||||
tail.Append(Ascii("stan@box:~$ ls"));
|
||||
|
||||
var held = Encoding.ASCII.GetString(tail.Snapshot().Tail);
|
||||
|
||||
held.Length.ShouldBe(OutputTail.Capacity);
|
||||
held.ShouldEndWith("aaastan@box:~$ ls");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AReadLargerThanTheTail_KeepsItsEnd()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
tail.Append(Ascii(new string('x', 5000) + "the end"));
|
||||
|
||||
Encoding.ASCII.GetString(tail.Snapshot().Tail).ShouldEndWith("xthe end");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheAlternateScreen_IsOnBetweenItsTwoSwitches()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
tail.Append(Ascii("$ vi notes\r\n\x1b[?1049h\x1b[H~\r\n~"));
|
||||
tail.Snapshot().AlternateScreen.ShouldBeTrue();
|
||||
|
||||
tail.Append(Ascii("\x1b[?1049l$ "));
|
||||
tail.Snapshot().AlternateScreen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASwitchSplitAcrossTwoReads_IsStillSeen()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
tail.Append(Ascii("$ less log\r\n\x1b[?10"));
|
||||
tail.Append(Ascii("49h"));
|
||||
|
||||
tail.Snapshot().AlternateScreen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASwitchAtTheStartOfAHugeRead_IsNotLostWithTheBytesThatFallOffTheTail()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
// A full-screen program's first frame: the switch, and then a screenful of paint.
|
||||
tail.Append(Ascii("\x1b[?1049h" + new string('#', 60_000)));
|
||||
|
||||
tail.Snapshot().AlternateScreen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherPrivateModes_AreNotTheAlternateScreen()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
// Bracketed paste and cursor visibility, which every prompt sends.
|
||||
tail.Append(Ascii("\x1b[?2004h\x1b[?25h$ "));
|
||||
|
||||
tail.Snapshot().AlternateScreen.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,36 @@ public sealed class SyncCursorTests
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => SyncCursor.Encode(Key, VaultId, -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CarriesTheHorizonItWasIssuedUnder()
|
||||
{
|
||||
var cursor = SyncCursor.Encode(Key, VaultId, 12, horizon: 40);
|
||||
|
||||
SyncCursor.TryDecode(Key, cursor, VaultId, out var sequence, out var horizon).ShouldBeTrue();
|
||||
sequence.ShouldBe(12);
|
||||
horizon.ShouldBe(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnderNoHorizon_KeepsTheFirstFormat()
|
||||
{
|
||||
// So a server from before horizons, still running beside a newer one mid-deploy, reads what the newer
|
||||
// one issues for every vault nothing has been collected from.
|
||||
SyncCursor.Encode(Key, VaultId, 12, horizon: 0).ShouldBe(SyncCursor.Encode(Key, VaultId, 12));
|
||||
|
||||
SyncCursor.TryDecode(Key, SyncCursor.Encode(Key, VaultId, 12), VaultId, out _, out var horizon)
|
||||
.ShouldBeTrue();
|
||||
horizon.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACursorWithAHorizon_IsStillBoundToItsVault()
|
||||
{
|
||||
var cursor = SyncCursor.Encode(Key, OtherVaultId, 12, horizon: 40);
|
||||
|
||||
SyncCursor.TryDecode(Key, cursor, VaultId, out _, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentSequencesProduceDifferentCursors()
|
||||
{
|
||||
|
||||