Public Access
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e61fc72b4 | ||
|
|
8a4058ce3a | ||
|
|
2d95366552 | ||
|
|
9ead42171c | ||
|
|
597e5ae016 | ||
|
|
483ae1fed9 | ||
|
|
7ee2aacdc9 | ||
|
|
dec2cf164a | ||
|
|
7d1f0a4b53 | ||
|
|
fd92b80d4f | ||
|
|
dabc691c35 | ||
|
|
31c836f6ca | ||
|
|
b8c94ecb73 | ||
|
|
aff776cb42 | ||
|
|
91f7586b22 | ||
|
|
c859705b83 | ||
|
|
ed936380b3 | ||
|
|
c270f3fe39 | ||
|
|
56c19b006d | ||
|
|
1908189100 | ||
|
|
2810f7c12b | ||
|
|
c0dd5a147e | ||
|
|
c0396b4f4d |
No files matched your search
@@ -29,6 +29,18 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: build and test
|
name: build and test
|
||||||
runs-on: [linux]
|
runs-on: [linux]
|
||||||
|
# ◆ A CEILING, BECAUSE THIS FILE HAD NONE AND A HUNG TEST RAN UNTIL SOMEBODY NOTICED.
|
||||||
|
#
|
||||||
|
# One test in DodoSSH.Client.App.Tests waited on a named pipe that, on Linux, nothing was ever going
|
||||||
|
# to answer — and it waited on a token with no timeout, so the job did not fail, it sat there. It was
|
||||||
|
# killed by hand after twenty minutes, having reported nothing but the eighteen suites that had
|
||||||
|
# already passed.
|
||||||
|
#
|
||||||
|
# Twenty-five minutes against a job that ordinarily takes three. It is not a performance budget and
|
||||||
|
# must not be read as one: a build that legitimately grows past this should have the number raised.
|
||||||
|
# What it buys is that the failure mode is a red job with a named step rather than a runner somebody
|
||||||
|
# has to go and stop.
|
||||||
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
# act_runner runs every `uses:` action with node inside the job container, and this
|
# act_runner runs every `uses:` action with node inside the job container, and this
|
||||||
# runner's image has none — the run died on the first line of actions/checkout with
|
# runner's image has none — the run died on the first line of actions/checkout with
|
||||||
@@ -220,7 +232,12 @@ jobs:
|
|||||||
- name: build
|
- name: build
|
||||||
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
|
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
|
||||||
|
|
||||||
|
# Tighter than the job's own ceiling, and deliberately so: a hang here is the likeliest of the
|
||||||
|
# lot — it is the only step running code written for this repository rather than a tool — and a
|
||||||
|
# step-level timeout names the step in the failure instead of leaving a reader to work out which
|
||||||
|
# of fifteen was still running. Ten minutes against a suite that takes two.
|
||||||
- name: test
|
- name: test
|
||||||
|
timeout-minutes: 10
|
||||||
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
||||||
|
|
||||||
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
||||||
@@ -556,6 +573,8 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
android:
|
android:
|
||||||
name: android head
|
name: android head
|
||||||
|
# See the build job. The docker pull and the toolchain image make this one legitimately slower.
|
||||||
|
timeout-minutes: 40
|
||||||
runs-on: [linux]
|
runs-on: [linux]
|
||||||
# Writes the nightly release at the end of the job; see that step for what the capability is and why
|
# Writes the nightly release at the end of the job; see that step for what the capability is and why
|
||||||
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
|
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
|
||||||
@@ -915,7 +934,10 @@ jobs:
|
|||||||
# never made.
|
# never made.
|
||||||
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||||
for name in $names; do
|
for name in $names; do
|
||||||
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
# grep without -q, which has the same exit status and reads to the end of its input. A
|
||||||
|
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
|
||||||
|
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
|
||||||
|
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||||
echo "The release was created but $name is not on it:" >&2
|
echo "The release was created but $name is not on it:" >&2
|
||||||
printf '%s\n' "$published" >&2
|
printf '%s\n' "$published" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -926,6 +948,8 @@ jobs:
|
|||||||
|
|
||||||
desktop:
|
desktop:
|
||||||
name: desktop nightly
|
name: desktop nightly
|
||||||
|
# See the build job. Two publishes, a pack and an upload of about 120 MB.
|
||||||
|
timeout-minutes: 40
|
||||||
# Gated on the tests, like the image job and for a stronger version of its reason: this one is
|
# Gated on the tests, like the image job and for a stronger version of its reason: this one is
|
||||||
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
|
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
|
||||||
# commit whose suite was red.
|
# commit whose suite was red.
|
||||||
@@ -1226,7 +1250,10 @@ jobs:
|
|||||||
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
|
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
|
||||||
|
|
||||||
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
|
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
|
||||||
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
# grep without -q, which has the same exit status and reads to the end of its input. A
|
||||||
|
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
|
||||||
|
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
|
||||||
|
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||||
echo "The release was created but $name is not on it:" >&2
|
echo "The release was created but $name is not on it:" >&2
|
||||||
printf '%s\n' "$published" >&2
|
printf '%s\n' "$published" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -1279,7 +1306,8 @@ jobs:
|
|||||||
# upload it never made.
|
# upload it never made.
|
||||||
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||||
for name in $linuxNames; do
|
for name in $linuxNames; do
|
||||||
if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$name\""; then
|
# grep without -q; see the sibling loops above and scripts/ci-linux-rpm.sh.
|
||||||
|
if ! printf '%s' "$withRpm" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||||
echo "The release was created but $name is not on it:" >&2
|
echo "The release was created but $name is not on it:" >&2
|
||||||
printf '%s\n' "$withRpm" >&2
|
printf '%s\n' "$withRpm" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -1290,6 +1318,8 @@ jobs:
|
|||||||
|
|
||||||
image:
|
image:
|
||||||
name: api image
|
name: api image
|
||||||
|
# See the build job. A cold layer cache is the slow case here.
|
||||||
|
timeout-minutes: 40
|
||||||
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
||||||
# clock on every main commit and buys the thing worth having: no image reaches the
|
# clock on every main commit and buys the thing worth having: no image reaches the
|
||||||
# registry from a commit whose tests were red. An image is not a build artefact anyone
|
# registry from a commit whose tests were red. An image is not a build artefact anyone
|
||||||
|
|||||||
+8
-1
@@ -87,9 +87,16 @@ Releases/
|
|||||||
*.pfx
|
*.pfx
|
||||||
*.p12
|
*.p12
|
||||||
*.snk
|
*.snk
|
||||||
|
# Signing keystores, with one exception that is tracked deliberately. build/nightly.keystore is
|
||||||
|
# committed in the open on purpose — see ADR 0014 for why a key everybody holds is what makes the
|
||||||
|
# nightly channel safe to run in CI — and a file already tracked is unaffected by an ignore rule
|
||||||
|
# anyway. The negation is here so that reading this list does not suggest the tracked one is an
|
||||||
|
# oversight. The *release* keystore is a real secret under ADR 0011 rule 1 and must never land here.
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
!build/nightly.keystore
|
||||||
appsettings.Local.json
|
appsettings.Local.json
|
||||||
appsettings.*.Local.json
|
appsettings.*.Local.json
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!deploy/.env.example
|
|
||||||
secrets/
|
secrets/
|
||||||
@@ -69,7 +69,8 @@ src/
|
|||||||
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
||||||
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
||||||
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
|
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
|
||||||
DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked
|
DodoSSH.Client.Import reading ~/.ssh/config and the keys it names when asked; reading a Hetzner
|
||||||
|
Cloud project over its API
|
||||||
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
|
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
|
||||||
DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
|
DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
|
||||||
DodoSSH.Client.Android the phone head: its views, and its Android integration
|
DodoSSH.Client.Android the phone head: its views, and its Android integration
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
# Development dependencies only: PostgreSQL and Keycloak.
|
# Development dependencies only: PostgreSQL and Keycloak.
|
||||||
#
|
#
|
||||||
# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the
|
# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the
|
||||||
# schema is still churning. The production stack is deploy/docker-compose.yml.
|
# schema is still churning.
|
||||||
|
#
|
||||||
|
# There is no production compose file in this repository. This one said there was, naming a
|
||||||
|
# deploy/docker-compose.yml that has never existed in any commit. What ships instead is the
|
||||||
|
# container image the CI image job builds and pushes; a compose file to run it belongs to the
|
||||||
|
# self-hosting guide in M4, and until that exists the honest thing to say is that it does not.
|
||||||
#
|
#
|
||||||
# docker compose -f deploy/docker-compose.dev.yml up -d
|
# docker compose -f deploy/docker-compose.dev.yml up -d
|
||||||
# dotnet run --project src/DodoSSH.Api
|
# dotnet run --project src/DodoSSH.Api
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
"post.logout.redirect.uris": "http://127.0.0.1/*"
|
"post.logout.redirect.uris": "http://127.0.0.1/*"
|
||||||
},
|
},
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"http://127.0.0.1/callback"
|
"http://127.0.0.1/callback",
|
||||||
|
"dev.dodotech.dodossh:/callback"
|
||||||
],
|
],
|
||||||
"webOrigins": [],
|
"webOrigins": [],
|
||||||
"protocolMappers": [
|
"protocolMappers": [
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# ADR 0015 — The Android terminal's input path leaves the WebView
|
||||||
|
|
||||||
|
- Status: proposed — **Stage 1 built, unverified on a device**
|
||||||
|
- Date: 2026-08-25
|
||||||
|
- Builds on: [`docs/android-port.md`](../android-port.md) §9, [ADR 0011](0011-android-distribution.md)
|
||||||
|
- Does not settle: whether the renderer stays xterm.js. That is [Stage 3](#stage-3--the-renderer-deliberately-unsettled), and it is deliberately left open.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The phone got the desktop's terminal, and the reasons were good ones. `TerminalDataPlane` already
|
||||||
|
served the page and a binary protocol over a loopback WebSocket, the xterm.js bundles were embedded
|
||||||
|
resources and platform-neutral, and `Avalonia.Controls.WebView` shipped a `net10.0-android36.0`
|
||||||
|
target. `docs/android-port.md` called it *"better news than expected"*, and for rendering it was.
|
||||||
|
|
||||||
|
**For typing it was not, and typing is the complaint.** Not scrollback, not colour, not throughput —
|
||||||
|
the act of putting characters into a shell. That narrows the problem usefully, because the input path
|
||||||
|
and the render path are separable, and only one of them is wrong.
|
||||||
|
|
||||||
|
### Three things sit between a finger and a byte, and the middle one is the fault
|
||||||
|
|
||||||
|
**1. Avalonia and the WebView fight over native focus.** `AvaloniaView.DispatchTouchEvent` ends every
|
||||||
|
handled touch — DOWN and UP alike — with a `RequestFocus()` for Avalonia's own view. The WebView's
|
||||||
|
input connection dies with its focus, so the keyboard swaps layouts and the inset churn can park it
|
||||||
|
over the accessory row that was tapped. `Platform/TerminalFocus.cs` exists to hand focus back, posted
|
||||||
|
to the next main-looper message so the platform has taken its turn first. It works. It is also a
|
||||||
|
counter-move in a fight that only exists because the thing holding the input connection is not the
|
||||||
|
thing the framework thinks is focused.
|
||||||
|
|
||||||
|
**2. The keyboard is a prose keyboard, and no DOM attribute makes it stop.** This is the one that was
|
||||||
|
worth checking rather than assuming, and the check inverted the obvious conclusion.
|
||||||
|
|
||||||
|
The vendored xterm.js *already* sets every hint the web platform has on its helper textarea —
|
||||||
|
`autocapitalize="off"`, `autocorrect="off"`, `spellcheck="false"`. They are set, and Gboard shows the
|
||||||
|
suggestion strip and predictive text anyway. So this is not a missing attribute; it is a ceiling.
|
||||||
|
|
||||||
|
Termux's `TerminalView.onCreateInputConnection` says what the ceiling is. It sets:
|
||||||
|
|
||||||
|
```
|
||||||
|
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||||
|
```
|
||||||
|
|
||||||
|
**`VISIBLE_PASSWORD` is the load-bearing half.** `NO_SUGGESTIONS` alone is advisory and most keyboards
|
||||||
|
ignore it — a point made in Avalonia's own Android keyboard PR discussion, not only in Termux's. What
|
||||||
|
actually turns off autocorrect, the suggestion strip and the predictive composing layer is the
|
||||||
|
*variation*: a field the keyboard believes is a visible password gets no dictionary. That is an
|
||||||
|
`EditorInfo.inputType` on a native `InputConnection`, and a page inside a WebView has no way to reach
|
||||||
|
it — Chromium computes the `EditorInfo` from the focused DOM node, and no combination of attributes on
|
||||||
|
a `<textarea>` produces the visible-password variation.
|
||||||
|
|
||||||
|
**3. The accessory row is a workaround for a keyboard, not a keyboard.** Esc, Tab, Ctrl and the arrows
|
||||||
|
are right and stay. But every tap on one costs the focus round-trip in (1), and none of them can be
|
||||||
|
held or repeated.
|
||||||
|
|
||||||
|
### The finding that decides this ADR
|
||||||
|
|
||||||
|
Avalonia already emits Termux's exact `inputType`. Decompiled from the pinned
|
||||||
|
`Avalonia.Android` 12.1.1, `AndroidInputMethod<TView>.OnCreateInputConnection`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
|
||||||
|
{
|
||||||
|
outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`0x80080` is `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS`. The guard reads:
|
||||||
|
`ContentType` left at `Normal`, `Multiline` false, `AutoCapitalization` false, and
|
||||||
|
`TextInputOptions.ShowSuggestions` explicitly `false` — a `bool?` whose default is `null`, so it must
|
||||||
|
be set, not merely left alone. The same method also ORs `IME_FLAG_NO_EXTRACT_UI |
|
||||||
|
IME_FLAG_NO_FULLSCREEN` into `imeOptions`, which is the other thing Termux sets and for the same
|
||||||
|
reason: a terminal must never be replaced by a full-screen extract editor in landscape.
|
||||||
|
|
||||||
|
So the keyboard behaviour that makes Termux feel like a terminal is **already available on the
|
||||||
|
version this repository pins**, to any Avalonia control that asks for it. It is not available to the
|
||||||
|
WebView, and never will be. That asymmetry is the whole decision.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Avalonia owns the keystrokes. The WebView renders and nothing else.
|
||||||
|
|
||||||
|
A focusable Avalonia control — `TerminalInputSurface` — becomes the terminal's input target. It draws
|
||||||
|
nothing. It sits in the same `Panel` as the renderer, takes focus when the terminal surface is shown,
|
||||||
|
and keeps it for as long as that surface is up.
|
||||||
|
|
||||||
|
- `TextInputOptions.ShowSuggestions = false`, with `ContentType`, `Multiline` and `AutoCapitalization`
|
||||||
|
left at the defaults the guard above requires. This is the entire fix for the suggestion strip.
|
||||||
|
- `TextInputOptions.ReturnKeyType = Return`, which is the fix for Enter — see the correction below.
|
||||||
|
- It implements `TextInputMethodClient` with an empty surrounding text and a zero-length selection.
|
||||||
|
A terminal has no document for an IME to edit; committed text is forwarded and nothing is retained.
|
||||||
|
- Committed text and key events are encoded to bytes and written to the session through the same path
|
||||||
|
`TerminalScreen`'s accessory keys already use. No new transport, no change to `TerminalDataPlane`.
|
||||||
|
- The native WebView is made non-focusable (`Focusable = false`, `FocusableInTouchMode = false`) once,
|
||||||
|
at attach.
|
||||||
|
|
||||||
|
**`Platform/TerminalFocus.cs` is deleted by this, not amended.** The fight it referees ends when only
|
||||||
|
one side is a focus candidate. Its remarks should move into the new control as the record of why the
|
||||||
|
WebView is deliberately unfocusable, because that reasoning outlives the workaround.
|
||||||
|
|
||||||
|
`Platform/SoftKeyboard.cs` stays. Dismissing a keyboard on the way to a sheet is still a platform
|
||||||
|
call, and Avalonia still offers no way to close one.
|
||||||
|
|
||||||
|
### Not: patching xterm.js's textarea
|
||||||
|
|
||||||
|
Already tried by xterm.js itself, and already failing — see Context (2). The attributes are set. The
|
||||||
|
platform ignores them. There is no version of this that reaches `VISIBLE_PASSWORD` from inside a
|
||||||
|
WebView, so the effort buys nothing.
|
||||||
|
|
||||||
|
### Not: a native Android `View`, Termux-shaped, today
|
||||||
|
|
||||||
|
Termux's `TerminalView` + `TerminalRenderer` drawing to a `Canvas` is the right architecture for a
|
||||||
|
phone-only terminal, and DodoSSH is not a phone-only terminal. Writing one now means two terminal
|
||||||
|
implementations — this one and the desktop's — with two escape-sequence behaviours to keep honest
|
||||||
|
against each other, for a defect that is entirely in the input path. That is a large bill for the
|
||||||
|
wrong problem.
|
||||||
|
|
||||||
|
It also does not become unavailable later. Stage 3 keeps it on the table.
|
||||||
|
|
||||||
|
### Not: replacing xterm.js now
|
||||||
|
|
||||||
|
Rendering is not the complaint. xterm.js with the WebGL addon draws a correct, fast grid, and
|
||||||
|
`terminal.js` already handles renderer death, reattach and refit — hard-won behaviour recorded in
|
||||||
|
`docs/android-port.md` that a rewrite would have to re-earn. Replacing a working renderer to fix a
|
||||||
|
keyboard is the wrong trade, and doing it in the same change as the input move would make neither
|
||||||
|
half attributable when the result is judged on a device.
|
||||||
|
|
||||||
|
## Staging
|
||||||
|
|
||||||
|
### Stage 1 — the input surface · **built**
|
||||||
|
|
||||||
|
The decision above, and nothing else. This is the change that answers the complaint, and it should be
|
||||||
|
judged on a device on its own before anything else is built on it.
|
||||||
|
|
||||||
|
What landed:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| `Views/TerminalInputSurface.cs` | The 1×1 focusable control, `ShowSuggestions = false`, the IME client that retains nothing, and the key→escape-sequence table |
|
||||||
|
| `Views/TerminalInputEventArgs.cs` | One keystroke's bytes, on the way to `TerminalScreen.SendAsync` |
|
||||||
|
| `Platform/RendererFocus.cs` | Makes the `WebView` non-focusable and reports taps on it |
|
||||||
|
| `Platform/TerminalFocus.cs` | **Deleted.** The fight it refereed has one contestant now |
|
||||||
|
| `Views/TerminalScreen.axaml{,.cs}` | Hosts the surface, raises the keyboard on arrival, drops the two posted focus counter-moves from the accessory row |
|
||||||
|
|
||||||
|
**One thing gained that was not asked for, and it was a real defect:** the Ctrl latch now reaches the
|
||||||
|
software keyboard. It never did before — everything typed went straight into the page and never passed
|
||||||
|
`SendAsync`, which is where the latch lives — so `ctrl` then `c` was inert unless the second key also came
|
||||||
|
from the accessory row. Check 11.10d covers it.
|
||||||
|
|
||||||
|
**Verify before building anything on this:** checks 11.10a–11.10e in `docs/manual-checks.md`, and 11.10b
|
||||||
|
first of all, because it is the falsification test at the end of this document.
|
||||||
|
|
||||||
|
> **⚠️ Corrected on the first device run: every Enter closed the keyboard.** The premise held — the
|
||||||
|
> suggestion strip, autocorrect and predictive text were all gone — but the field had been left with
|
||||||
|
> Avalonia's default `ReturnKeyType`, and the same `OnCreateInputConnection` maps that, on a field that is
|
||||||
|
> not `Multiline`, to `IME_ACTION_DONE`:
|
||||||
|
>
|
||||||
|
> ```csharp
|
||||||
|
> _ => options.Multiline ? 1073741824 : 6, // 6 is IME_ACTION_DONE
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Done means done: the keyboard performs the action and dismisses itself. On a text box that is right, and
|
||||||
|
> on a terminal it fires on every command. `TextInputReturnKeyType.Return` — value 1, so
|
||||||
|
> `returnKeyType - 1 => 0 => 1073741824` — is `IME_ACTION_NONE`, which leaves Enter a plain newline with no
|
||||||
|
> action for the IME to perform and no reason for it to hide.
|
||||||
|
>
|
||||||
|
> **The trap worth recording, because it is the same trap 11.10b already warned about from the other side:**
|
||||||
|
> `Multiline = true` also reaches `IME_ACTION_NONE`, through the default arm, and is the wrong way there. It
|
||||||
|
> ORs `TYPE_TEXT_FLAG_MULTI_LINE` into `InputType`, which moves it off `1` and silently fails the
|
||||||
|
> `ShowSuggestions` guard. Taking the obvious route to a working Enter would have handed the suggestion
|
||||||
|
> strip back and looked like the whole ADR had failed. `ReturnKeyType` touches `imeOptions` only.
|
||||||
|
>
|
||||||
|
> Enter's *byte* was never in doubt and is not what changed — `IME_ACTION_DONE` was already synthesising
|
||||||
|
> `KEYCODE_ENTER` through `AvaloniaInputConnection.PerformEditorAction`, so commands ran; the keyboard just
|
||||||
|
> left afterwards. `OnTextInput` now also normalises line endings to CR, for the case where a keyboard with
|
||||||
|
> no editor action commits `"\n"` as ordinary text instead of sending a key event, and so that a pasted
|
||||||
|
> `"\r\n"` does not run its line twice.
|
||||||
|
|
||||||
|
### Stage 2 — the keys around it
|
||||||
|
|
||||||
|
Only once Stage 1 is proven, because each of these is cheap *given* Avalonia owning input and awkward
|
||||||
|
without it:
|
||||||
|
|
||||||
|
- **Hold-to-repeat** on the arrows and Backspace. Currently impossible without a focus round-trip per
|
||||||
|
tap.
|
||||||
|
- **Ctrl held rather than latched**, with latching kept as the fallback. `TerminalScreen`'s remark is
|
||||||
|
right that latching is what phone SSH clients do; held becomes possible when one control sees both
|
||||||
|
the modifier press and the following keystroke.
|
||||||
|
- **Pinch and volume keys for font size**, replacing the pinned `A−`/`A+` buttons. Both are what
|
||||||
|
JuiceSSH does and what its users name first; the buttons stay as the discoverable path.
|
||||||
|
|
||||||
|
Volume-key capture is an `onKeyDown` on the activity and is deliberately opt-in — a phone that cannot
|
||||||
|
change its volume with a shell open is a worse phone.
|
||||||
|
|
||||||
|
### Stage 3 — the renderer, deliberately unsettled
|
||||||
|
|
||||||
|
If, after Stages 1 and 2, the terminal still reads as a web page — touch selection, scrollback
|
||||||
|
survival across renderer death, scroll feel — then the renderer is the next question, and the
|
||||||
|
candidates are known:
|
||||||
|
|
||||||
|
| Option | What it is | Cost |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [XTerm.NET](https://github.com/tomlm/XTerm.NET) | MIT, headless, UI-agnostic; VT100/ANSI, dual buffers, 256/true colour, wide chars | Evaluate emulation fidelity against xterm.js |
|
||||||
|
| [VtNetCore](https://github.com/darrenstarr/VtNetCore) | MIT, .NET Standard 2.0, older but proven | Maintenance status |
|
||||||
|
| Port xterm.js's core | Exact behavioural parity with the desktop | Largest, and a permanent fork |
|
||||||
|
|
||||||
|
Rendered by a custom Avalonia `Control` via `DrawGlyphRun`, which is Termux's architecture expressed
|
||||||
|
in Avalonia and would work on the desktop too — eventually letting the WebView2 dependency go, and
|
||||||
|
with it the `targetSdk` 36 floor `Avalonia.Controls.WebView` currently forces.
|
||||||
|
|
||||||
|
**Stage 1 is not wasted if Stage 3 happens.** The input surface is exactly the layer such a control
|
||||||
|
would need, unchanged. That is the reason for splitting them.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**What gets better, and it is one thing done properly.** No suggestion strip, no autocorrect, no
|
||||||
|
predictive composing layer, no full-screen extract editor, and no focus flap on an accessory key.
|
||||||
|
That is the difference between typing at a shell and fighting a phone.
|
||||||
|
|
||||||
|
**CJK and other composing input is degraded, and this is a real cost.** `VISIBLE_PASSWORD` suppresses
|
||||||
|
composition, which is precisely what makes it work for a terminal and precisely what a Chinese,
|
||||||
|
Japanese or Korean user needs. Termux has the same limitation and lives with it. If it ever needs
|
||||||
|
answering, the answer is a per-session toggle that drops `ShowSuggestions = false` and accepts the
|
||||||
|
prose keyboard back — not a middle setting, because there isn't one.
|
||||||
|
|
||||||
|
**Text selection and copy are unchanged**, still xterm.js's own DOM handling, still without touch
|
||||||
|
selection handles — [xterm.js #5377](https://github.com/xtermjs/xterm.js/issues/5377) is open and
|
||||||
|
unassigned and this ADR does not fix it. Stage 3 territory.
|
||||||
|
|
||||||
|
**Scrollback still dies with the renderer process.** Unchanged, still honest about it via the replay
|
||||||
|
banner, still Stage 3 territory.
|
||||||
|
|
||||||
|
**Nothing here is verifiable headless**, for the reason `docs/manual-checks.md` §11 already gives.
|
||||||
|
Stage 1 needs new manual checks — at minimum: the suggestion strip is absent with a shell open; an
|
||||||
|
accessory key does not disturb the keyboard (§11.10a, which should now pass trivially rather than by
|
||||||
|
posted counter-move); landscape does not produce a full-screen extract editor; and a hardware
|
||||||
|
keyboard still reaches the shell.
|
||||||
|
|
||||||
|
**The hardware-keyboard path moves.** It currently arrives through the WebView; after this it arrives
|
||||||
|
through Avalonia. Behaviour should be identical and probably will not be on the first try.
|
||||||
|
|
||||||
|
## What would falsify this
|
||||||
|
|
||||||
|
If, on a device, `ShowSuggestions = false` does not suppress the suggestion strip — because the guard
|
||||||
|
was not satisfied, or the keyboard ignores the variation too — then Stage 1's premise is wrong and
|
||||||
|
the honest next step is Stage 3 with a native `InputConnection`, not more Avalonia. **Check this
|
||||||
|
first, on the smallest possible spike, before building `TerminalInputSurface` properly.** It is one
|
||||||
|
attached property on a focusable control and a look at the keyboard.
|
||||||
@@ -269,6 +269,12 @@ accessory key row for this. That is UI work, not porting.
|
|||||||
> reconnected; earlier output stayed on the host"* — is that honesty put where the person looking at the
|
> reconnected; earlier output stayed on the host"* — is that honesty put where the person looking at the
|
||||||
> terminal will actually read it, not buried in a log.
|
> terminal will actually read it, not buried in a log.
|
||||||
|
|
||||||
|
> **⚠️ Under review, on the input half only.** The renderer's story above stands; what has not held up in
|
||||||
|
> use is *typing*. A software keyboard over a WebView cannot be told to stop autocorrecting — the DOM
|
||||||
|
> attributes xterm.js already sets are advisory and Android ignores them — and the accessory row pays a
|
||||||
|
> focus round-trip per tap. [ADR 0015](adr/0015-android-terminal-input.md) proposes moving the input path to
|
||||||
|
> Avalonia and leaving the renderer exactly as this section describes it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Decisions taken
|
## Decisions taken
|
||||||
@@ -749,6 +755,12 @@ Recorded so they are choices rather than accidents. Any of them is cheap to revi
|
|||||||
local-and-non-roaming requirement more cleanly than any desktop platform does.
|
local-and-non-roaming requirement more cleanly than any desktop platform does.
|
||||||
- **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns
|
- **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns
|
||||||
something like `localhost` on Android and would make every entry from a phone indistinguishable.
|
something like `localhost` on Android and would make every entry from a phone indistinguishable.
|
||||||
|
- **The Hetzner Cloud importer is not ported, and it is the one gap here that is not a platform limit.**
|
||||||
|
Its view model is in `DodoSSH.Client.Shell` like every other and its reader is an `HttpClient` call, so
|
||||||
|
both halves already run on a phone — unlike the `~/.ssh/config` importer beside it, which has no file to
|
||||||
|
read under scoped storage and never will. What is missing is a layout: the desktop draws a five-column
|
||||||
|
table of servers, and a phone version of that is a different screen rather than the same one narrower.
|
||||||
|
Said on the Preferences screen rather than left to be discovered.
|
||||||
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
|
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
|
||||||
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
|
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
|
||||||
confirmed to exist before anything is written to solve it.
|
confirmed to exist before anything is written to solve it.
|
||||||
|
|||||||
@@ -741,3 +741,27 @@ to a 34-pixel rail carrying the chevron that brings it back — a rail rather th
|
|||||||
that vanishes without trace is one people report as lost. The choice is written through to
|
that vanishes without trace is one people report as lost. The choice is written through to
|
||||||
`ClientSettings.SessionSidebarOpen` rather than held for the session: it is a decision about how much of the
|
`ClientSettings.SessionSidebarOpen` rather than held for the session: it is a decision about how much of the
|
||||||
window a terminal gets, and one that had to be made again on every launch would not really be on offer.
|
window a terminal gets, and one that had to be made again on every launch would not really be on offer.
|
||||||
|
|
||||||
|
## v5c-5 — one door to settings, not three
|
||||||
|
|
||||||
|
**The user-chip popover keeps its Settings row and has lost Vaults and Preferences.** v5c gave it three, and
|
||||||
|
the table above records why: until that pass "Settings" and "Preferences" had been two doors onto the same
|
||||||
|
bare screen, so splitting them was a real fix, and Vaults arrived beside them when `VaultsScreen` became a
|
||||||
|
settings page too.
|
||||||
|
|
||||||
|
Living with it made the shape of the mistake clear. All three opened the same full-window mode and differed
|
||||||
|
only in which of its seven pages they landed on — and `SettingsNav`, which lists every one of those pages
|
||||||
|
including Vaults and Preferences, is on screen the entire time you are in there. So two of the three rows
|
||||||
|
were deep links into a place the third one opens, saving one click and costing a menu that read as though it
|
||||||
|
offered three destinations. A menu at the foot of the rail is for getting into settings at all; which page
|
||||||
|
you want is a question that has a rail of its own once you are there.
|
||||||
|
|
||||||
|
Nothing became unreachable: both pages are one click deeper, from the rail that was always going to be the
|
||||||
|
way to them. `NavRailTests` asserts the rows are gone from the markup rather than merely unwired, because a
|
||||||
|
row left behind with no handler is a menu entry that does nothing when pressed — which reads as a broken
|
||||||
|
application rather than a tidied menu.
|
||||||
|
|
||||||
|
Logout stays, and still lands on a page rather than on General: its confirmation card is drawn inline on the
|
||||||
|
Account page and nowhere else, so arming it from anywhere else would be a card raised where nobody could see
|
||||||
|
it. The vault switches and **New vault** above the divider stay too — they are what the popover is for
|
||||||
|
besides settings, and neither is a page of the settings mode.
|
||||||
+413
-26
@@ -118,15 +118,17 @@ view models — this check is the version with a real connection on the end of i
|
|||||||
|
|
||||||
### 1.8 Entering and leaving settings mode
|
### 1.8 Entering and leaving settings mode
|
||||||
|
|
||||||
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings**. Then repeat from a
|
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings** — which is the menu's
|
||||||
fresh open, choosing **Vaults**, and again choosing **Preferences**.
|
only way in now; the Vaults and Preferences rows beside it were folded back into it, because all three
|
||||||
|
opened this same mode and differed only in which page they landed on.
|
||||||
|
|
||||||
**Pass:** each of the three swaps the whole window's chrome — the titlebar becomes the 53px "Back to
|
**Pass:** it swaps the whole window's chrome — the titlebar becomes the 53px "Back to application" bar, the
|
||||||
application" bar, the ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the
|
ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the update banner (if either
|
||||||
update banner (if either was showing) are both gone. **Settings** lands on General; **Vaults** and
|
was showing) are both gone. It lands on **General**, with SettingsNav's own list beside it —
|
||||||
**Preferences** land on their own pages, each lit in SettingsNav's own list — General/Vaults/Account under
|
General/Vaults/Account under SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below
|
||||||
SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below both. Click every one of the
|
both. Click every one of the seven rows in turn: each swaps the content column without leaving settings mode
|
||||||
seven rows in turn: each swaps the content column without leaving settings mode or touching the titlebar.
|
or touching the titlebar, and Vaults and Preferences are among them — which is the whole reason the popover
|
||||||
|
no longer carries its own rows for those two.
|
||||||
|
|
||||||
Now open a terminal, leave it showing, and open Settings from the user chip again. Press **Back to
|
Now open a terminal, leave it showing, and open Settings from the user chip again. Press **Back to
|
||||||
application** — or, without touching anything else, press Esc.
|
application** — or, without touching anything else, press Esc.
|
||||||
@@ -134,7 +136,8 @@ application** — or, without touching anything else, press Esc.
|
|||||||
**Pass:** both return to exactly the screen that was showing before Settings was opened — the terminal,
|
**Pass:** both return to exactly the screen that was showing before Settings was opened — the terminal,
|
||||||
still running, rather than HOSTS or anywhere else. Switching between settings pages first (General → Vaults
|
still running, rather than HOSTS or anywhere else. Switching between settings pages first (General → Vaults
|
||||||
→ Preferences) does not change what "back" goes back to; only the screen open the moment Settings was
|
→ Preferences) does not change what "back" goes back to; only the screen open the moment Settings was
|
||||||
**first** entered does.
|
**first** entered does. The other doors into this mode — the palette, and the Preferences page's own two
|
||||||
|
importer rows — behave the same way, so the popover having one row rather than three changes nothing here.
|
||||||
|
|
||||||
**Failure means:** a return to HOSTS regardless of where Settings was opened from is
|
**Failure means:** a return to HOSTS regardless of where Settings was opened from is
|
||||||
`MainWindowViewModel.settingsReturnScreen` being recaptured on every `EnterSettings` call rather than only on
|
`MainWindowViewModel.settingsReturnScreen` being recaptured on every `EnterSettings` call rather than only on
|
||||||
@@ -165,7 +168,9 @@ ordinary nav rail destination does through `MainWindowViewModel.ShowScreen`. An
|
|||||||
comes up over settings mode the same way it would over any other screen.
|
comes up over settings mode the same way it would over any other screen.
|
||||||
|
|
||||||
**Failure means:** a back button that never changes label is `SettingsTitleBar.axaml`'s two buttons both
|
**Failure means:** a back button that never changes label is `SettingsTitleBar.axaml`'s two buttons both
|
||||||
bound to the same side of `IsImportOpen`. SettingsNav lighting a different row while the importer is up is
|
bound to the same side of `IsImporterOpen` — which is the flag now, rather than `IsImportOpen`, because
|
||||||
|
there are two importers over that page and the bar behaves identically for both. SettingsNav lighting a
|
||||||
|
different row while an importer is up is
|
||||||
`MainWindowViewModel.IsSettingsPreferencesPage` reading `IsImportOpen` when it must not —
|
`MainWindowViewModel.IsSettingsPreferencesPage` reading `IsImportOpen` when it must not —
|
||||||
`Import.dc.html` draws the rail unmoved on purpose; see the remark on `IsImportOpen`. Ctrl+K doing nothing
|
`Import.dc.html` draws the rail unmoved on purpose; see the remark on `IsImportOpen`. Ctrl+K doing nothing
|
||||||
over the importer would be a guard added to `OnKeyDown` for `IsSettingsMode` that the design never asked for
|
over the importer would be a guard added to `OnKeyDown` for `IsSettingsMode` that the design never asked for
|
||||||
@@ -197,6 +202,126 @@ That is what the "is this still the page's socket" guard in `connect()` exists t
|
|||||||
input while the banner is *clear* is neither: the socket is open and dead, which nothing on this page can
|
input while the banner is *clear* is neither: the socket is open and dead, which nothing on this page can
|
||||||
currently see — see the note at the end of 11.12a.
|
currently see — see the note at the end of 11.12a.
|
||||||
|
|
||||||
|
### 1.10a A remote that floods the terminal does not grow the process · **watch Task Manager**
|
||||||
|
|
||||||
|
Open a shell and run `yes dodossh`. Leave it running for a full minute with the tab in front of you, then
|
||||||
|
switch to another screen and leave it another minute, then come back. Watch the application's memory in
|
||||||
|
Task Manager (Details tab, *Memory (active private working set)*) the whole time.
|
||||||
|
|
||||||
|
**Pass:** memory settles at a plateau and stays there — the number moves as the GC breathes, but there is no
|
||||||
|
staircase. Ctrl+C, and the prompt comes back straight away. Somewhere in the scrollback there may be a dim
|
||||||
|
grey line reading `── N MB of output was dropped; the terminal could not keep up ──`; that line is a pass,
|
||||||
|
not a failure. It is this client saying it discarded output it could not draw, which is what keeps the
|
||||||
|
plateau flat, and the number after it tells you how far behind the display got.
|
||||||
|
|
||||||
|
**Failure means:** a working set that climbs steadily for the whole minute and does not come back down after
|
||||||
|
Ctrl+C is the defect this check exists for, and it will not be in this repository's own buffers — it is
|
||||||
|
SSH.NET's. `ShellStream` copies everything the channel delivers into a buffer that doubles and is never
|
||||||
|
given back, and the channel keeps delivering because `Channel.OnData` re-opens the SSH receive window as
|
||||||
|
bytes *arrive* rather than as they are consumed. Measured against the container in
|
||||||
|
`DodoSSH.Client.Ssh.Tests`, a reader that stops entirely leaves that buffer holding 44 MB after five
|
||||||
|
seconds. The only thing that keeps it empty is `TerminalSessionPump` reading continuously and bounding what
|
||||||
|
it holds in `OutputBacklog` instead — so a staircase here means something has put a wait back into that read
|
||||||
|
loop. `ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog` is the test that fails first.
|
||||||
|
|
||||||
|
### 1.10b A tab whose shell exited stops costing anything · **needs a debugger or `netstat`**
|
||||||
|
|
||||||
|
Open a shell, type `exit`, and **leave the tab open**. Note the host and port. Then, from a terminal outside
|
||||||
|
the application, run `netstat -ano | findstr <port>` and look for the connection to that host.
|
||||||
|
|
||||||
|
**Pass:** the connection is gone within a second or two of the shell exiting, even though the tab is still on
|
||||||
|
screen showing the session ended and still offering RECONNECT. Clicking that reconnect still works, and
|
||||||
|
closing the tab afterwards still works.
|
||||||
|
|
||||||
|
**Failure means:** a connection that is still ESTABLISHED for as long as the tab is open is the session
|
||||||
|
holding an authenticated socket it has no further use for — with a keep-alive putting a packet on the wire
|
||||||
|
every thirty seconds and SSH.NET's read buffer still at whatever size the session's busiest moment grew it
|
||||||
|
to. It used to do exactly that, until whichever tab it was got closed. `TerminalWorkspace.ReleaseAsync` is
|
||||||
|
what gives it back now, and `ASessionWhoseRemoteHasExited_GivesBackItsConnectionWithoutWaitingForTheTab` is
|
||||||
|
the test that says so.
|
||||||
|
|
||||||
|
### 1.11 The right button copies and pastes, and never opens a menu
|
||||||
|
|
||||||
|
Open a shell and run something that prints a few lines — `ls -la /etc`. Drag a selection across part of the
|
||||||
|
output with the left button, then **right-click inside the selection**.
|
||||||
|
|
||||||
|
**Pass:** the highlight disappears at once, **no menu appears anywhere**, and the text is on the clipboard —
|
||||||
|
paste it into Notepad, or into the connect box, to be sure. The pane still takes typing straight afterwards.
|
||||||
|
|
||||||
|
Now **right-click again with nothing selected.**
|
||||||
|
|
||||||
|
**Pass:** the clipboard's text appears at the prompt as one insertion and **Enter is not pressed** — it sits
|
||||||
|
there waiting. On bash it will be highlighted in reverse video until the next keystroke, which is right: that
|
||||||
|
is bash marking a bracketed paste, and it is how you can see the markers arrived.
|
||||||
|
|
||||||
|
**And the multi-line case, which is the one worth the most care.** Copy three separate lines out of a text
|
||||||
|
editor — `cd /tmp`, `pwd`, `ls` — and right-click in the terminal.
|
||||||
|
|
||||||
|
**Pass:** all three lines land at the prompt as one thing being edited and **nothing runs** until Enter is
|
||||||
|
pressed. Three commands executing the moment you right-click is the failure this is looking for.
|
||||||
|
|
||||||
|
**On a Mac, one more:** right-click over a word with nothing selected and confirm it pastes rather than
|
||||||
|
selecting the word under the pointer. xterm.js defaults `rightClickSelectsWord` to true on macOS alone;
|
||||||
|
`terminal.js` turns it off, and this is the one platform where forgetting to would show.
|
||||||
|
|
||||||
|
**What the tests already own.** `RendererClipboardTests` runs `terminal.js` itself and proves which frame a
|
||||||
|
right-click sends, which session it is addressed to, that the selection is dropped after a copy and that the
|
||||||
|
answer comes back through `paste()` rather than `input()`. `ShellFlowTests` proves the round trip through the
|
||||||
|
host's own clipboard. What is left here is the WebView: that a real `contextmenu` event reaches a listener
|
||||||
|
registered in the capture phase, that no native menu is drawn once it is prevented, and that xterm's real
|
||||||
|
`paste()` brackets the text — none of which a Jint engine can answer.
|
||||||
|
|
||||||
|
**Failure means:**
|
||||||
|
|
||||||
|
- **A context menu appears.** The event is not being prevented, or not being seen. `handleRightClick` takes
|
||||||
|
it in the capture phase on the pane; a listener registered without the third argument to
|
||||||
|
`addEventListener` would still prevent the menu, so a menu means the listener is not attached at all.
|
||||||
|
- **A permission prompt appears** — *"…wants to see text and images copied to the clipboard"*. Somebody has
|
||||||
|
routed the read through `navigator.clipboard.readText()`, which is exactly what the `ClipboardPaste`
|
||||||
|
opcode exists to avoid. The clipboard is the host's.
|
||||||
|
- **A drag started exactly where you last right-clicked selects nothing.** xterm's own `contextmenu`
|
||||||
|
handler ran: it parks its invisible helper textarea under the pointer, 20 pixels square at z-index 1000,
|
||||||
|
to serve the native menu. The page stops the event as well as preventing it, and `stopPropagation` is the
|
||||||
|
half that was left out. Subtle enough to be worth looking for on purpose rather than waiting to notice.
|
||||||
|
- **Three commands run on a multi-line paste.** The text went through `input()` rather than `paste()`, so
|
||||||
|
bracketed paste never happened and the shell read each newline as "run this". That is the whole reason the
|
||||||
|
`Clipboard` opcode is separate from `Paste`; see `TerminalFrame.cs`.
|
||||||
|
- **Nothing happens at all, in either direction.** The head is not passing a clipboard. Both pass one — see
|
||||||
|
`ClipboardWriter` and `ClipboardReader` in each `App.axaml.cs` — and the view model is deliberately silent
|
||||||
|
when neither is there, because there is no status line on this screen to say it on.
|
||||||
|
|
||||||
|
### 1.12 Something is on screen while it starts
|
||||||
|
|
||||||
|
Close DodoSSH and start it again. Watch the first second.
|
||||||
|
|
||||||
|
**Pass:** a small panel — about 420 pixels wide, no titlebar — appears saying **DodoSSH** and *Starting…*,
|
||||||
|
and is then replaced by the main window. Measured on a debug build against a cold
|
||||||
|
profile: the panel at 668 ms, the window at 3.1 s. A release install is quicker and the shape is the same.
|
||||||
|
What must never come back is the old behaviour, which was nothing at all on screen for the whole of it.
|
||||||
|
|
||||||
|
**Pass, and worth checking separately:** the panel is **gone** once the window is up, there is never a moment
|
||||||
|
with two windows both claiming to be DodoSSH, and clicking another application while the panel is up puts
|
||||||
|
that application in front of it. The last one is deliberate — a splash that stays on top is a splash sitting
|
||||||
|
over whatever you gave up and went back to.
|
||||||
|
|
||||||
|
**And the failure state, which is the part with something to get wrong.** It needs provoking: put a
|
||||||
|
*directory* where `cache.db` should be — `%LOCALAPPDATA%\DodoSSH.Nightly\cache.db` for the nightly — and
|
||||||
|
start the application.
|
||||||
|
|
||||||
|
**Pass:** the panel stays and the line becomes *DodoSSH could not start:* followed by the reason and
|
||||||
|
*Press Escape to close.* Escape closes it. Remove the directory afterwards.
|
||||||
|
|
||||||
|
There is deliberately nothing else on the card to contradict that line — the progress bar this check used to
|
||||||
|
describe was taken off in "Take the frozen bar off the splash, and leave the line that is true", because an
|
||||||
|
indeterminate bar over a composition that had stopped was the one thing a splash must never do.
|
||||||
|
|
||||||
|
**Failure means:** a panel left saying *Starting…* is the main window having failed to show with nothing
|
||||||
|
catching it, which is the one outcome worse than the crash it replaced — `DodoSshApp.ComposeBehind` exists to
|
||||||
|
make it impossible. Two windows at once is the show-then-close order in that method having been swapped:
|
||||||
|
closing the only open window ends the process, so the real window is shown while the panel is still up.
|
||||||
|
Nothing appearing at all means composition is running in the same dispatcher turn that has to paint the
|
||||||
|
panel, which is what posting it at `DispatcherPriority.Background` prevents.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2 — Known Hosts as its own page
|
## Phase 2 — Known Hosts as its own page
|
||||||
@@ -360,6 +485,74 @@ machine. That is the ordinary case rather than an error.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Importing from Hetzner Cloud
|
||||||
|
|
||||||
|
The reader has its own suite over the shapes the API returns, and the end-to-end path is covered by
|
||||||
|
`ImportingFromHetzner_ShowsTheProjectFirstAndThenStoresWhatWasTicked` against a stubbed transport. What no
|
||||||
|
test in this repository does is talk to Hetzner — there is no account, no token and no network in the
|
||||||
|
ordinary suite — so everything below needs a real project.
|
||||||
|
|
||||||
|
### 2.11a A read-only token lists your project
|
||||||
|
|
||||||
|
Make a token in the Hetzner console under **Security → API tokens** with **Read** permission only. Settings
|
||||||
|
→ Preferences → **OPEN WIZARD**, paste it, press **READ THE PROJECT**.
|
||||||
|
|
||||||
|
**Pass:** every server in that project is listed, with the address, the plan and the location the Hetzner
|
||||||
|
console shows for it. The token box is **empty** afterwards — it is used for one request and dropped — and
|
||||||
|
the Hosts screen is unchanged, because reading writes nothing.
|
||||||
|
|
||||||
|
**What to look for specifically:**
|
||||||
|
- A project with more than fifty servers must list all of them. Fifty is Hetzner's page size, and a list
|
||||||
|
that stops there exactly is the paginator not being followed.
|
||||||
|
- A server with no public IPv4 should show the first address of its `/64` with a warning chip saying so.
|
||||||
|
Check that address against what the server actually answers on — the convention is Hetzner's own images,
|
||||||
|
not a fact the API states, and a machine whose operator moved it elsewhere in the `/64` will not answer.
|
||||||
|
- A server the console shows as **off** should still be listed, with `off` in the STATE column.
|
||||||
|
|
||||||
|
**Failure means:** the importer and the console disagree about what the project holds, which produces
|
||||||
|
bookmarks that dial the wrong machine. Worse than an import that refused.
|
||||||
|
|
||||||
|
### 2.11b A wrong token says so, and says where tokens come from
|
||||||
|
|
||||||
|
Paste anything that is not a valid token — a truncated one is the realistic case, since the console shows
|
||||||
|
the value once.
|
||||||
|
|
||||||
|
**Pass:** the wizard stays on the token step, the message names the Hetzner console and the Security → API
|
||||||
|
tokens page, and the box is cleared for a fresh paste. Nothing has been stored.
|
||||||
|
|
||||||
|
Now try a token from a **different** project.
|
||||||
|
|
||||||
|
**Pass:** it lists that project instead. A token belongs to one project and there is no API call that will
|
||||||
|
say which — an account with several projects runs this once per project, which the note above the box says.
|
||||||
|
|
||||||
|
### 2.11c The private-network choice does not fall back
|
||||||
|
|
||||||
|
With a project holding at least one server on a private network and one on none, press **PRIVATE NETWORK**.
|
||||||
|
|
||||||
|
**Pass:** the server with no private network shows an em dash for its address, a chip reading *no address of
|
||||||
|
that kind*, and a tickbox that cannot be ticked — including by the tick-all box in the header. The other
|
||||||
|
one shows its `10.x` address.
|
||||||
|
|
||||||
|
**Failure means:** a public address quietly stored under a choice that asked to stay off the internet. This
|
||||||
|
is the one asymmetry in how addresses resolve and it is deliberate; v4 and v6 fall back to each other,
|
||||||
|
private does not.
|
||||||
|
|
||||||
|
### 2.11d Imported hosts are correct, and ask for a password
|
||||||
|
|
||||||
|
Import a couple, then open one on the Hosts screen.
|
||||||
|
|
||||||
|
**Pass:** the address, port and login user are what the wizard showed, and the notes carry the server id,
|
||||||
|
the plan, the location and the image — and **not** the power state, which would be stale the moment somebody
|
||||||
|
pressed a button in the console. Connecting asks for a **password**: no key came with these hosts, because
|
||||||
|
Hetzner has never held the private half of one. Bind it to a key in your keychain and it connects.
|
||||||
|
|
||||||
|
Now go back to the list and import the same project again.
|
||||||
|
|
||||||
|
**Pass:** the ones already stored are marked *already here* and arrive unticked. Importing one twice is
|
||||||
|
allowed and takes a click.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Phase 2 — Drag and drop on the SFTP page
|
## Phase 2 — Drag and drop on the SFTP page
|
||||||
|
|
||||||
**This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native
|
**This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native
|
||||||
@@ -1687,6 +1880,27 @@ bottom and no scroll position left part way down.
|
|||||||
**Failure means:** the inset is being applied but not cleared — the closed state is not being read from the
|
**Failure means:** the inset is being applied but not cleared — the closed state is not being read from the
|
||||||
event, or the margin is only ever added to.
|
event, or the margin is only ever added to.
|
||||||
|
|
||||||
|
### 10.4a Nothing is stranded when the keyboard is still up on the way *out* · **the one 10.4 missed**
|
||||||
|
|
||||||
|
Focus any box, and with the keyboard still on screen leave the application — Home, or the task switcher,
|
||||||
|
or anything that puts another app in front. Come back to it.
|
||||||
|
|
||||||
|
**Pass:** the interface fills the screen. Do it from the terminal too, where the keypad and the shell should
|
||||||
|
both be where they were.
|
||||||
|
|
||||||
|
**Failure means:** `PhoneShell` is not re-reading the inset on the way back in. This is a separate path from
|
||||||
|
10.4 and it is the one that has actually shipped broken: Android takes the keyboard away when the activity
|
||||||
|
stops and Avalonia's input pane raises no `StateChanged` for it, because the pane goes with the window
|
||||||
|
rather than closing in front of one. So the last value applied was for a keyboard that was up, and the
|
||||||
|
application returns holding a whole keyboard's height of empty canvas — measured at 838 of 2340 pixels on a
|
||||||
|
Pixel 5, on every screen, and staying that way until something opens and closes the keyboard. See
|
||||||
|
`PhoneShell.OnActivityResumed` and `PhoneEnvironment.Resumed`.
|
||||||
|
|
||||||
|
Worth doing in both directions: a resume that *should* keep the inset is the other half. Trigger a
|
||||||
|
permission prompt or the biometric sheet with the keyboard up, dismiss it, and check the box being typed
|
||||||
|
into is still clear of a keyboard that is genuinely still there — a fix that flattened the inset on every
|
||||||
|
resume would pass the paragraph above and fail this one.
|
||||||
|
|
||||||
### 10.5 Rotating with the keyboard up
|
### 10.5 Rotating with the keyboard up
|
||||||
|
|
||||||
Focus a passphrase box, then turn the phone sideways.
|
Focus a passphrase box, then turn the phone sideways.
|
||||||
@@ -1840,12 +2054,22 @@ Now tap **Tab** on the accessory row, or **A+**, and type again.
|
|||||||
keyboard.
|
keyboard.
|
||||||
|
|
||||||
**Failure means:** the buttons on that row have become focusable again. An ordinary Avalonia button takes
|
**Failure means:** the buttons on that row have become focusable again. An ordinary Avalonia button takes
|
||||||
focus on tap, which takes it off the `NativeWebView` — and the package's own `OnLostFocus` then resigns the
|
focus on tap, which takes it off `TerminalInputSurface`. The row goes on working, because its keys are
|
||||||
page's focus. The row goes on working, because its keys are pressed rather than typed into, so the symptom
|
pressed rather than typed into, so the symptom is a terminal that answers the buttons and ignores the
|
||||||
is a terminal that answers the buttons and ignores the keyboard: it reads as the session having died. See
|
keyboard: it reads as the session having died. See `Focusable = false` in `TerminalScreen.axaml.cs`.
|
||||||
`Focusable = false` in `TerminalScreen.axaml.cs` and the focus entry in `docs/platform-flags.md`.
|
|
||||||
|
|
||||||
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
|
**Also worth typing while a hardware keyboard is attached**, because that path moved under
|
||||||
|
[ADR 0015](adr/0015-android-terminal-input.md) and is the one most likely to have been broken by the move:
|
||||||
|
it used to arrive through the `WebView`'s own key handling and now arrives through Avalonia's. Confirm that
|
||||||
|
Ctrl-C interrupts **and leaves no stray `c` at the prompt** — Avalonia raises the letter's own text event
|
||||||
|
whether or not the key event was handled, so a doubled character here means `controlByteAlreadySent` is not
|
||||||
|
doing its job — that the arrow keys reach shell history, that Backspace deletes one character, and that Tab
|
||||||
|
completes a path. **Forward-delete is a known, deliberate loss** — the Delete key sends a backspace,
|
||||||
|
because Avalonia turns an IME's `deleteSurroundingText` into a `KEYCODE_FORWARD_DEL` and a broken backspace
|
||||||
|
on every phone is worse than a missing forward-delete on the few devices with the key. See
|
||||||
|
`TerminalInputSurface.TryTranslate`.
|
||||||
|
|
||||||
|
Worth doing on the software keyboard too, where the old fault showed as the keyboard closing on the first
|
||||||
tap of an arrow key.
|
tap of an arrow key.
|
||||||
|
|
||||||
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
|
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
|
||||||
@@ -1853,18 +2077,98 @@ tap of an arrow key.
|
|||||||
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
|
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
|
||||||
keep typing on the software keyboard.
|
keep typing on the software keyboard.
|
||||||
|
|
||||||
**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
|
**Pass:** the keyboard settles back unchanged — same layout, same height — and everything typed after the
|
||||||
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
|
tap still reaches the terminal. The accessory row stays visible above the keyboard throughout. There should
|
||||||
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
|
be **no blink at all** now: under [ADR 0015](adr/0015-android-terminal-input.md) the input target is an
|
||||||
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
|
Avalonia control, so the focus the platform hands to Avalonia's own view after every dispatched touch is
|
||||||
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
|
already the focus the keyboard wants, and there is nothing to hand back.
|
||||||
|
|
||||||
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
|
**Failure means:** the buttons on that row have become focusable again, taking Avalonia's focus off
|
||||||
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
|
`TerminalInputSurface`. The old failure — a keyboard that swapped layouts and parked itself over the row —
|
||||||
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
|
belonged to the arrangement where the `WebView` held the input connection and is not reachable from here;
|
||||||
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
|
if it reappears, the renderer has taken focus back and `RendererFocus.Suppress` did not run. See check
|
||||||
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
|
11.10c.
|
||||||
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
|
|
||||||
|
### 11.10b The keyboard over a terminal is a terminal's keyboard · **the important one for ADR 0015**
|
||||||
|
|
||||||
|
Open a shell and look at the keyboard, without typing anything yet. Then type a few words of ordinary
|
||||||
|
English — `ls`, `cd etc`, `sudo apt update`.
|
||||||
|
|
||||||
|
**Pass:** there is **no suggestion strip** above the keys, nothing is autocapitalised at the start of a line,
|
||||||
|
no word is autocorrected, and no predictive text appears ahead of the cursor. Backspace deletes exactly one
|
||||||
|
character per press. Rotating to landscape does not replace the terminal with a full-screen text editor.
|
||||||
|
|
||||||
|
**Failure means:** the premise of ADR 0015 is wrong and everything staged on it needs rethinking, so find
|
||||||
|
out *which* half failed before concluding anything. Either `TextInputOptions.ShowSuggestions` is not
|
||||||
|
reaching Avalonia's `AndroidInputMethod` — the guard there is narrow and needs `InputType` to be exactly
|
||||||
|
`TYPE_CLASS_TEXT`, so anything that sets `ContentType`, `Multiline` or `AutoCapitalization` on
|
||||||
|
`TerminalInputSurface` or an ancestor silently disables it, `inherits: true` being the trap — or the
|
||||||
|
keyboard is ignoring `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD`, which would mean no Avalonia-side fix exists
|
||||||
|
and the honest next step is a native input connection. Worth repeating on a second keyboard (Gboard and one
|
||||||
|
other) before deciding, because this is a claim about keyboards rather than about Android.
|
||||||
|
|
||||||
|
### 11.10c The renderer cannot take the keyboard, and a tap still brings it back
|
||||||
|
|
||||||
|
With a shell open, put the keyboard away with the system back gesture. Then tap the terminal. Separately:
|
||||||
|
with the keyboard up, drag the terminal to scroll its scrollback.
|
||||||
|
|
||||||
|
**Pass:** the tap brings the keyboard straight back. The drag scrolls the scrollback and does **not** raise
|
||||||
|
the keyboard. At no point does a text caret or a selection handle appear inside the terminal.
|
||||||
|
|
||||||
|
**Failure means:** if the tap does nothing, `RendererFocus.Suppress` never found the `WebView` — it is
|
||||||
|
called from two places, the posted `EnvironmentRequested` handler and the `IsTerminalShowing` transition,
|
||||||
|
and a miss in both leaves no tap listener. If the drag raises the keyboard, the touch-slop test is not
|
||||||
|
working. If a caret appears, the `WebView` is still focusable and the page has taken the input connection
|
||||||
|
back, which is the whole failure ADR 0015 exists to prevent.
|
||||||
|
|
||||||
|
### 11.10d The Ctrl latch reaches the software keyboard
|
||||||
|
|
||||||
|
With a shell open and something running that can be interrupted — `sleep 60`, or `top` — tap **ctrl** on the
|
||||||
|
accessory row and then press **c** on the software keyboard.
|
||||||
|
|
||||||
|
**Pass:** the process is interrupted, and the ctrl key returns to its unlatched colour.
|
||||||
|
|
||||||
|
**Failure means:** typed characters are not going through `TerminalScreen.SendAsync`. Note that this check
|
||||||
|
could not pass *before* ADR 0015 and is not a regression test but a new capability: the latch used to apply
|
||||||
|
only to the accessory row's own keys, because everything typed went straight into the page and never passed
|
||||||
|
the code that holds the latch.
|
||||||
|
|
||||||
|
### 11.10e Enter runs the command and the keyboard stays where it is
|
||||||
|
|
||||||
|
With a shell open and the software keyboard up, type `pwd` and press Enter. Then type another command.
|
||||||
|
|
||||||
|
**Pass:** the command runs and **the keyboard is still up**, unmoved, ready for the next one. The Enter key
|
||||||
|
reads as a return arrow rather than as `DONE` or a tick. Ten commands in a row cost ten Enters and no taps
|
||||||
|
to bring the keyboard back.
|
||||||
|
|
||||||
|
**Failure means:** `TextInputOptions.ReturnKeyType` is not `Return` on `TerminalInputSurface`, so Avalonia
|
||||||
|
has mapped the field's default to `IME_ACTION_DONE` and the keyboard is doing exactly what Done means. This
|
||||||
|
was the first defect the first device run found. **Do not fix it with `Multiline = true`** — that reaches
|
||||||
|
the same `IME_ACTION_NONE` by a route that ORs `TYPE_TEXT_FLAG_MULTI_LINE` into `InputType`, which fails the
|
||||||
|
`ShowSuggestions` guard and hands the suggestion strip back; check 11.10b would then start failing and the
|
||||||
|
cause would look unrelated. See the correction in [ADR 0015](adr/0015-android-terminal-input.md).
|
||||||
|
|
||||||
|
Worth pressing Enter on an empty prompt a few times too: each one should give a fresh prompt and nothing
|
||||||
|
else. A doubled prompt per press means a line ending is arriving as both CR and LF.
|
||||||
|
|
||||||
|
### 11.10f Every key on the keypad is there, and pressing one presses it
|
||||||
|
|
||||||
|
With a shell open, look at the two rows above the keyboard without touching anything: **esc tab ctrl | - ~ /**
|
||||||
|
on the upper row and **← ↓ ↑ →** with **A−** and **A+** on the lower one. All thirteen, at once, on a 360dp
|
||||||
|
phone in portrait.
|
||||||
|
|
||||||
|
Then press them with a thumb rather than a fingertip, at an angle, twenty or so times across both rows —
|
||||||
|
including the keys at the far left and far right ends.
|
||||||
|
|
||||||
|
**Pass:** every press registers, the key darkens under the thumb while it is down, and **nothing slides
|
||||||
|
sideways**. The row is exactly where it was after twenty presses as it was before the first.
|
||||||
|
|
||||||
|
**Failure means:** a key that misses is a target that has got smaller — `Button.hotkey` is 40 tall for a
|
||||||
|
reason and Android asks for 48. A row that *moves* is the one that matters: it means something has put a
|
||||||
|
`ScrollViewer` back around the keys. That is how this row shipped originally and it does not survive contact
|
||||||
|
with a thumb — a pan and a press begin with the same few pixels of travel, the scroller claims them, and the
|
||||||
|
press is never delivered. If the set of keys outgrows the width, add a third row; do not make it scroll.
|
||||||
|
See the keypad's own remark in `TerminalScreen.axaml`.
|
||||||
|
|
||||||
### 11.11 Closing a connection and opening a new one both take you somewhere real
|
### 11.11 Closing a connection and opening a new one both take you somewhere real
|
||||||
|
|
||||||
@@ -1922,6 +2226,26 @@ without either end noticing, which a suspended renderer can leave behind — sho
|
|||||||
connected and swallows what is typed. If that is ever seen, it is a different bug from this one and needs a
|
connected and swallows what is typed. If that is ever seen, it is a different bug from this one and needs a
|
||||||
liveness probe rather than a faster retry.
|
liveness probe rather than a faster retry.
|
||||||
|
|
||||||
|
### 11.13 The right button on a phone with a pointer · **needs a mouse, a trackpad or a dock**
|
||||||
|
|
||||||
|
The same page as 1.11 runs on this head, so the gesture is here too — but only for a device that has a
|
||||||
|
secondary click at all. Attach a mouse or use a keyboard case's trackpad, or dock the phone, open a shell,
|
||||||
|
select some output and right-click it; then right-click again with nothing selected.
|
||||||
|
|
||||||
|
**Pass:** exactly as 1.11 — the selection drops with no menu, and the second click pastes.
|
||||||
|
|
||||||
|
**Unverified, and that is why this check exists.** Nothing here has been run against Android's WebView with
|
||||||
|
a pointer attached, so whether it delivers a secondary click as a DOM `contextmenu` event at all is an open
|
||||||
|
question. `ClipboardReader` is wired on this head regardless, because the alternative is a head where the
|
||||||
|
paste half silently does nothing and nobody finds out.
|
||||||
|
|
||||||
|
**Failure means:** if the copy works and the paste does not, the clipboard read is being refused — Android
|
||||||
|
returns nothing from the clipboard to an app that is not the foreground one, and a WebView taking the
|
||||||
|
secondary click may be leaving Avalonia's window in a state where `TopLevel` has none. If neither works,
|
||||||
|
Android is not delivering the click as a `contextmenu` event, and the gesture needs a native listener on the
|
||||||
|
`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer
|
||||||
|
has no right button to miss.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 12 — Shared vaults: the operations that span two accounts
|
## Phase 12 — Shared vaults: the operations that span two accounts
|
||||||
@@ -2565,6 +2889,32 @@ having been broken, and the fallback is to move the offer into the titlebar inst
|
|||||||
the swap having been blocked, usually by a process still holding a file under `current\`. Being asked to
|
the swap having been blocked, usually by a process still holding a file under `current\`. Being asked to
|
||||||
enrol again means the profile directory did not survive, which is 16.1's failure arriving late.
|
enrol again means the profile directory did not survive, which is 16.1's failure arriving late.
|
||||||
|
|
||||||
|
### 16.7a Two releases published while it is running cost one update · **needs two releases**
|
||||||
|
|
||||||
|
The nightly channel is the easier place to do this — every push to `main` publishes one — but it works the
|
||||||
|
same way on the release channel with two tags. With the client running and the banner already up for one
|
||||||
|
build, publish a second. Then press **RESTART NOW** on the banner.
|
||||||
|
|
||||||
|
**Pass:** one download and one restart, landing on the **second** build. The banner says
|
||||||
|
*"A newer build appeared. Fetching it before restarting"* with a percentage while it does, and it stays on
|
||||||
|
screen throughout rather than vanishing. Starting up afterwards offers nothing, because there is nothing
|
||||||
|
left to offer.
|
||||||
|
|
||||||
|
**And the timer's half, if you have six hours or a debugger.** Leave the client running past a check
|
||||||
|
interval with a build already fetched and a newer one published. The banner's version changes on its own,
|
||||||
|
and LATER having been pressed does not stop it coming back — a different version is a different question.
|
||||||
|
|
||||||
|
**Failure means:** two downloads and two restarts is the regression this check exists for, and it was the
|
||||||
|
shipped behaviour: the background pass skipped itself entirely once anything was fetched, so a running
|
||||||
|
client was pinned to whichever build it first saw and installed that, then found the next one at the launch
|
||||||
|
straight afterwards. On a rolling channel — where the release is deleted and remade on every push, so the
|
||||||
|
feed holds exactly one build — that was the normal case rather than a corner.
|
||||||
|
|
||||||
|
A banner that disappears when RESTART NOW is pressed is `IsRestarting` not reaching it; everything the strip
|
||||||
|
draws keys off `IsReady`, and the last look moves the state off that. A restart that does not happen at all
|
||||||
|
when the forge is unreachable is the last look having become a gate: it is a courtesy, and the build already
|
||||||
|
on disk is still newer than the one running.
|
||||||
|
|
||||||
### 16.8 The first connect after an update is not a cold start
|
### 16.8 The first connect after an update is not a cold start
|
||||||
|
|
||||||
Immediately after 16.7, connect to a host.
|
Immediately after 16.7, connect to a host.
|
||||||
@@ -2610,6 +2960,43 @@ offer a nightly, ever, however many have been published since. It reads a differ
|
|||||||
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
|
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
|
||||||
rather than as a bug in the nightly.
|
rather than as a bug in the nightly.
|
||||||
|
|
||||||
|
### 16.11 One instance, and what a second launch does instead
|
||||||
|
|
||||||
|
With DodoSSH running, start it again — Start menu, desktop shortcut, or the executable directly. Do it three
|
||||||
|
times: with the window in front, with it minimised, and with it behind another application.
|
||||||
|
|
||||||
|
**Pass:** no second window appears, and the one already open comes forward, un-minimised if it was
|
||||||
|
minimised. **A flashing taskbar button rather than a raised window is also a pass**: Windows refuses the
|
||||||
|
foreground to a process that did not receive the input, which is its own rule about focus stealing and not
|
||||||
|
something to defeat. Nothing happening at all is the failure.
|
||||||
|
|
||||||
|
**And the side-by-side case, which is the whole reason the lock lives where it does.** 16.10's arrangement,
|
||||||
|
both builds installed, both started.
|
||||||
|
|
||||||
|
**Pass:** both run. They are separate applications with separate profiles, and the lock is a file inside the
|
||||||
|
profile — so neither can see the other's. Two people signed in to one machine is the same property from the
|
||||||
|
other direction and needs no check of its own.
|
||||||
|
|
||||||
|
**And the update relaunch · needs a release.** Take an update (16.7) and press RESTART NOW.
|
||||||
|
|
||||||
|
**Pass:** the application comes back. This is the race worth naming: the updater starts the new build as the
|
||||||
|
old one is exiting, and a lock held a moment too long would make the new process refuse itself and disappear
|
||||||
|
— an update that looked like an uninstall. `SingleInstance.Claim` keeps trying for three seconds while the
|
||||||
|
lock is held by something that does not answer its pipe, which is what covers it.
|
||||||
|
|
||||||
|
**Failure means:** a second window means the lock is not being taken. A profile directory that cannot be
|
||||||
|
written to answers "you are the only one" on purpose — refusing to start an SSH client over a permissions
|
||||||
|
problem would be the wrong trade — so check that the profile exists, is writable, and has an
|
||||||
|
`instance.lock` in it while the application runs. A second launch that exits without raising the first means
|
||||||
|
the lock worked and the doorbell did not: the pipe name is derived from the profile path, so two processes
|
||||||
|
disagreeing about it means they disagree about which profile they are in. Two windows *after an update* is
|
||||||
|
the opposite failure — the outgoing process never released the lock, which means something is holding it
|
||||||
|
open past `Shutdown`.
|
||||||
|
|
||||||
|
`SingleInstanceTests` covers everything reachable without a second process: the lock, its release, the
|
||||||
|
doorbell answering more than once, and two profiles not excluding each other. What is left for a person is a
|
||||||
|
second *executable* and a real window coming forward.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 17 — Installing and updating the phone
|
## Phase 17 — Installing and updating the phone
|
||||||
|
|||||||
+24
-5
@@ -223,6 +223,25 @@ echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
|
|||||||
#
|
#
|
||||||
# Everything above can succeed and still produce something nobody can install, and none of the ways
|
# Everything above can succeed and still produce something nobody can install, and none of the ways
|
||||||
# that happens are visible in an exit code. Each check below is one of them.
|
# that happens are visible in an exit code. Each check below is one of them.
|
||||||
|
#
|
||||||
|
# ◆ EVERY READER IN A PIPELINE HERE MUST CONSUME ALL OF ITS INPUT, AND THAT IS NOT A STYLE RULE.
|
||||||
|
#
|
||||||
|
# `set -o pipefail` at the top of this file means a pipeline's status is the rightmost non-zero one.
|
||||||
|
# A reader that stops early — `grep -q`, `head -n` — closes the pipe while the writer is still
|
||||||
|
# writing, the writer dies of SIGPIPE with status 141, and pipefail hands that 141 to the `if`. So a
|
||||||
|
# check that found what it was looking for reports the opposite, and a diagnostic printed on the way
|
||||||
|
# to `exit 1` kills the script under `set -e` before it gets there.
|
||||||
|
#
|
||||||
|
# It is a threshold rather than a constant, which is what made it look like somebody else's bug: the
|
||||||
|
# writer only blocks once it has more than a pipe buffer left to write, about 64 KB on Linux. `$files`
|
||||||
|
# is one line per packaged file, so this passed for as long as the published tree was small and then
|
||||||
|
# failed on the commit that pushed the path text past 64 KB — reporting `/opt/DodoSSH/DodoSSH` missing
|
||||||
|
# from a package that plainly contained it, and exiting 141 rather than the 1 it meant to.
|
||||||
|
#
|
||||||
|
# Hence `grep pattern >/dev/null` rather than `grep -q pattern`, which has the same exit status and
|
||||||
|
# reads to the end; and `awk 'NR<=40'` rather than `head -40`, which prints the same lines and reads
|
||||||
|
# to the end. Nothing here is allowed to stop early, and `|| true` is not the fix — it would hide a
|
||||||
|
# real failure of the writer alongside the imaginary one.
|
||||||
|
|
||||||
requires="$(rpm -qp --requires "$rpm_file")"
|
requires="$(rpm -qp --requires "$rpm_file")"
|
||||||
provides="$(rpm -qp --provides "$rpm_file")"
|
provides="$(rpm -qp --provides "$rpm_file")"
|
||||||
@@ -236,7 +255,7 @@ files="$(rpm -qp --list "$rpm_file")"
|
|||||||
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
|
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
|
||||||
unsatisfiable=""
|
unsatisfiable=""
|
||||||
for soname in $carried; do
|
for soname in $carried; do
|
||||||
if printf '%s\n' "$requires" | grep -qF "$soname("; then
|
if printf '%s\n' "$requires" | grep -F "$soname(" >/dev/null; then
|
||||||
unsatisfiable="$unsatisfiable $soname"
|
unsatisfiable="$unsatisfiable $soname"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -251,7 +270,7 @@ fi
|
|||||||
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
|
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
|
||||||
# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring
|
# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring
|
||||||
# the macro would break it silently and in the direction nobody looks.
|
# the macro would break it silently and in the direction nobody looks.
|
||||||
if printf '%s\n' "$provides" | grep -qE '^lib.*\.so'; then
|
if printf '%s\n' "$provides" | grep -E '^lib.*\.so' >/dev/null; then
|
||||||
echo "This package advertises bundled libraries to the system:" >&2
|
echo "This package advertises bundled libraries to the system:" >&2
|
||||||
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2
|
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2
|
||||||
echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2
|
echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2
|
||||||
@@ -262,7 +281,7 @@ fi
|
|||||||
# without libelf would do exactly that — the package would carry no glibc requirement at all and
|
# without libelf would do exactly that — the package would carry no glibc requirement at all and
|
||||||
# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a
|
# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a
|
||||||
# self-contained .NET tree cannot fail to have.
|
# self-contained .NET tree cannot fail to have.
|
||||||
if ! printf '%s\n' "$requires" | grep -q '^libc\.so\.6'; then
|
if ! printf '%s\n' "$requires" | grep '^libc\.so\.6' >/dev/null; then
|
||||||
echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2
|
echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2
|
||||||
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
|
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
|
||||||
printf '%s\n' "$requires" >&2
|
printf '%s\n' "$requires" >&2
|
||||||
@@ -279,9 +298,9 @@ for expected in \
|
|||||||
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
||||||
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
||||||
do
|
do
|
||||||
if ! printf '%s\n' "$files" | grep -qxF "$expected"; then
|
if ! printf '%s\n' "$files" | grep -xF "$expected" >/dev/null; then
|
||||||
echo "The package was built and $expected is not in it." >&2
|
echo "The package was built and $expected is not in it." >&2
|
||||||
printf '%s\n' "$files" | head -40 >&2
|
printf '%s\n' "$files" | awk 'NR<=40' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
+60
-10
@@ -169,16 +169,66 @@ Bump ApplicationVersion, commit, move the tag, and run this again.
|
|||||||
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
|
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
|
||||||
New-Item -ItemType Directory -Path $Staging | Out-Null
|
New-Item -ItemType Directory -Path $Staging | Out-Null
|
||||||
|
|
||||||
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
|
# ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE.
|
||||||
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
#
|
||||||
& dotnet build $Project --no-restore --configuration Release `
|
# They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is
|
||||||
-t:SignAndroidPackage `
|
# not. A process's command line is not a secret on Windows: any process running as this user can read
|
||||||
-p:DodoChannel=release `
|
# every argument of every other with one call —
|
||||||
-p:AndroidKeyStore=true `
|
#
|
||||||
-p:AndroidSigningKeyStore=$KeystorePath `
|
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||||
-p:AndroidSigningKeyAlias=$KeyAlias `
|
#
|
||||||
-p:AndroidSigningStorePass=$storePlain `
|
# — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The
|
||||||
-p:AndroidSigningKeyPass=$keyPlain
|
# comment above says the passwords are never stored, and with the arguments written that way it was
|
||||||
|
# true of the disk and false of everything watching the machine.
|
||||||
|
#
|
||||||
|
# env: is .NET for Android's own answer to exactly this, and it names the variable rather than carrying
|
||||||
|
# the value: MSBuild is handed the string "env:DODOSSH_ANDROID_STOREPASS" and reads the password out of
|
||||||
|
# the environment itself. The one restriction is $(AndroidPackageFormat) = aab, where env: is not
|
||||||
|
# supported and file: is the documented alternative; this channel ships an APK, and the guard below
|
||||||
|
# fails loudly rather than silently signing with the literal string if that ever changes.
|
||||||
|
#
|
||||||
|
# Not a perfect channel — an environment block is readable by a process with rights over this one, the
|
||||||
|
# same as memory is — but it is off the command line, out of the audit trail and out of any log that
|
||||||
|
# echoes the build invocation, which is the whole of the difference being bought here.
|
||||||
|
#
|
||||||
|
# -nodeReuse:false so no MSBuild worker outlives this call holding the variables in its environment.
|
||||||
|
# The finally clears them from this process either way, and a persistent node would have kept its own
|
||||||
|
# inherited copy for the next quarter of an hour regardless of what happens here.
|
||||||
|
$packageFormat = (& dotnet msbuild $Project -getProperty:AndroidPackageFormat -nologo `
|
||||||
|
-p:DodoChannel=release | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||||
|
|
||||||
|
if ($packageFormat -eq 'aab') {
|
||||||
|
Stop-With @'
|
||||||
|
This channel now builds an aab, and the env: prefix this script signs with is not supported for that
|
||||||
|
format. The signing would silently take "env:DODOSSH_ANDROID_STOREPASS" for the password itself.
|
||||||
|
|
||||||
|
Switch both passwords to the file: prefix, which is supported for aab, and make sure whatever writes
|
||||||
|
that file removes it again.
|
||||||
|
'@
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$env:DODOSSH_ANDROID_STOREPASS = $storePlain
|
||||||
|
$env:DODOSSH_ANDROID_KEYPASS = $keyPlain
|
||||||
|
|
||||||
|
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
|
||||||
|
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
||||||
|
& dotnet build $Project --no-restore --configuration Release `
|
||||||
|
-nodeReuse:false `
|
||||||
|
-t:SignAndroidPackage `
|
||||||
|
-p:DodoChannel=release `
|
||||||
|
-p:AndroidKeyStore=true `
|
||||||
|
-p:AndroidSigningKeyStore=$KeystorePath `
|
||||||
|
-p:AndroidSigningKeyAlias=$KeyAlias `
|
||||||
|
-p:AndroidSigningStorePass=env:DODOSSH_ANDROID_STOREPASS `
|
||||||
|
-p:AndroidSigningKeyPass=env:DODOSSH_ANDROID_KEYPASS
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
|
||||||
|
# and this runs on the failure path, where it may well not be.
|
||||||
|
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_STOREPASS', $null)
|
||||||
|
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_KEYPASS', $null)
|
||||||
|
}
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
|
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,11 @@
|
|||||||
# xcrun notarytool store-credentials DodoSSH \
|
# xcrun notarytool store-credentials DodoSSH \
|
||||||
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
|
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
|
||||||
#
|
#
|
||||||
# The forge token is the one real secret, and it is prompted for rather than read from a file or the
|
# The forge token is the one real secret, and it is prompted for rather than read from a file or an
|
||||||
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
|
# ambient environment variable, and only in the phase that needs it — for the reason the Windows script
|
||||||
# minutes a credential that can publish an update spends in a shell's memory the better.
|
# gives: the fewer minutes a credential that can publish an update spends in a shell's memory the better.
|
||||||
|
# It reaches vpk through VPK_TOKEN, exported for that one call and unset afterwards, because the
|
||||||
|
# alternative is --token on a command line that is not private. See the comment at the upload itself.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# bash scripts/release-macos.sh
|
# bash scripts/release-macos.sh
|
||||||
@@ -195,10 +197,18 @@ if [ "$UPLOAD" -eq 1 ]; then
|
|||||||
# also already hold the Windows package for the same tag, which is the case --merge is really doing
|
# also already hold the Windows package for the same tag, which is the case --merge is really doing
|
||||||
# the work for: without it the second platform to publish a given version fails on a release that
|
# the work for: without it the second platform to publish a given version fails on a release that
|
||||||
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
|
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
|
||||||
|
#
|
||||||
|
# No --token in this array, and that is deliberate. It used to be there, and an argument is not a
|
||||||
|
# private place to put a credential: /proc/<pid>/cmdline is world-readable on Linux, `ps` hands out
|
||||||
|
# other processes' arguments freely, and whatever agent is auditing process creation on this machine
|
||||||
|
# keeps its own copy. The comment above is about shell history and was never the whole story.
|
||||||
|
#
|
||||||
|
# vpk documents VPK_TOKEN as --token's environment fallback, so the value never becomes an argument.
|
||||||
|
# Exported for the one call and unset straight after; an environment block is still readable by a
|
||||||
|
# process with rights over this one, but it is out of every process listing and audit log.
|
||||||
UPLOAD_ARGS=(
|
UPLOAD_ARGS=(
|
||||||
upload gitea
|
upload gitea
|
||||||
--repoUrl "$REPO_URL"
|
--repoUrl "$REPO_URL"
|
||||||
--token "$TOKEN"
|
|
||||||
--outputDir "$RELEASES_DIR"
|
--outputDir "$RELEASES_DIR"
|
||||||
--channel "$CHANNEL"
|
--channel "$CHANNEL"
|
||||||
--releaseName "$TAG"
|
--releaseName "$TAG"
|
||||||
@@ -214,7 +224,15 @@ if [ "$UPLOAD" -eq 1 ]; then
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
write_step 'Uploading'
|
write_step 'Uploading'
|
||||||
dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'
|
|
||||||
|
# Unset on the way out whether or not the upload worked, so a failed release does not leave the token
|
||||||
|
# sitting in this shell for whatever runs next.
|
||||||
|
export VPK_TOKEN="$TOKEN"
|
||||||
|
vpk_status=0
|
||||||
|
dotnet vpk "${UPLOAD_ARGS[@]}" || vpk_status=$?
|
||||||
|
unset VPK_TOKEN
|
||||||
|
|
||||||
|
[ "$vpk_status" -eq 0 ] || stop_with 'vpk upload failed.'
|
||||||
|
|
||||||
write_step "Published $TAG."
|
write_step "Published $TAG."
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
|
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
|
||||||
so the bytes that reach users are the bytes that were installed and checked.
|
so the bytes that reach users are the bytes that were installed and checked.
|
||||||
|
|
||||||
The token is prompted for rather than read from a file or an environment variable, and only in the phase
|
The token is prompted for rather than read from a file or an ambient environment variable, and only in
|
||||||
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
|
the phase that needs it — the build does not, and the fewer minutes a credential that can publish an
|
||||||
in a shell's memory the better.
|
update spends in a shell's memory the better. It reaches vpk through VPK_TOKEN, set for that one call
|
||||||
|
and cleared afterwards, because the alternative is --token on a command line every process on this
|
||||||
|
machine can read. See the comment at the upload itself.
|
||||||
|
|
||||||
.PARAMETER Upload
|
.PARAMETER Upload
|
||||||
Publish the packages already in Releases/ instead of building.
|
Publish the packages already in Releases/ instead of building.
|
||||||
@@ -152,10 +154,24 @@ try {
|
|||||||
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
|
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
|
||||||
# fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
|
# fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
|
||||||
# same tag, so a release candidate is a prerelease in both channels or in neither.
|
# same tag, so a release candidate is a prerelease in both channels or in neither.
|
||||||
|
#
|
||||||
|
# ◆ NO --token HERE. IT WAS AN ARGUMENT, AND AN ARGUMENT IS PUBLIC ON THIS MACHINE.
|
||||||
|
#
|
||||||
|
# The comment above says the token never lands in shell history, which was true and was not the
|
||||||
|
# whole story: it went straight into vpk's command line, and any process running as this user can
|
||||||
|
# read that off a running process with one call —
|
||||||
|
#
|
||||||
|
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||||
|
#
|
||||||
|
# — with process-creation auditing and EDR agents keeping their own copy afterwards. A token that
|
||||||
|
# can publish an update every installed copy will fetch and run deserves better than that.
|
||||||
|
#
|
||||||
|
# vpk documents VPK_TOKEN as --token's environment fallback, so the flag simply goes away and the
|
||||||
|
# value never becomes an argument at all. An environment block is still readable by a process with
|
||||||
|
# rights over this one; what it is not is a string sitting in a process listing and an audit log.
|
||||||
$uploadArgs = @(
|
$uploadArgs = @(
|
||||||
'upload', 'gitea',
|
'upload', 'gitea',
|
||||||
'--repoUrl', $RepoUrl,
|
'--repoUrl', $RepoUrl,
|
||||||
'--token', $token,
|
|
||||||
'--outputDir', $ReleasesDir,
|
'--outputDir', $ReleasesDir,
|
||||||
'--channel', $Channel,
|
'--channel', $Channel,
|
||||||
'--releaseName', $tag,
|
'--releaseName', $tag,
|
||||||
@@ -169,7 +185,17 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Step 'Uploading'
|
Write-Step 'Uploading'
|
||||||
& dotnet vpk @uploadArgs
|
|
||||||
|
try {
|
||||||
|
$env:VPK_TOKEN = $token
|
||||||
|
& dotnet vpk @uploadArgs
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
|
||||||
|
# and this runs on the failure path, where it may well not be.
|
||||||
|
[Environment]::SetEnvironmentVariable('VPK_TOKEN', $null)
|
||||||
|
}
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
|
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
|
||||||
|
|
||||||
Write-Step "Published $tag."
|
Write-Step "Published $tag."
|
||||||
|
|||||||
@@ -115,10 +115,20 @@ public sealed class RelayOptions
|
|||||||
/// Whether RFC1918 and other private ranges may be dialled.
|
/// Whether RFC1918 and other private ranges may be dialled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
|
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
|
||||||
/// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
|
/// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
|
||||||
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. Loopback,
|
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
|
||||||
/// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing reads this setting yet, and that is the point of saying so here.</b> The relay is
|
||||||
|
/// an ADR and a contract; there is no endpoint and no dialler. This option exists so the shape
|
||||||
|
/// is settled before the code is, which makes it exactly the kind of comment that gets read as a
|
||||||
|
/// description of an implemented control. It is not one. Whoever builds the dialler owes ADR 0004
|
||||||
|
/// §3 in full — loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses
|
||||||
|
/// denied with no configuration escape, and IPv4-mapped IPv6 normalised and re-checked — and this
|
||||||
|
/// flag governs RFC1918 only, never that list.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool AllowPrivateNetworks { get; set; } = true;
|
public bool AllowPrivateNetworks { get; set; } = true;
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,13 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
var shell = new PhoneShell();
|
var shell = new PhoneShell();
|
||||||
|
|
||||||
var viewModel = ComposeShell(
|
var viewModel = ComposeShell(
|
||||||
paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell));
|
paths,
|
||||||
|
caches,
|
||||||
|
workspace,
|
||||||
|
knownHosts,
|
||||||
|
connections,
|
||||||
|
ClipboardWriter(shell),
|
||||||
|
ClipboardReader(shell));
|
||||||
|
|
||||||
shell.DataContext = viewModel;
|
shell.DataContext = viewModel;
|
||||||
|
|
||||||
@@ -176,6 +182,26 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Reads this phone's clipboard.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The other direction, looked up per call for the same reason and with the same fall-through. One
|
||||||
|
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
|
||||||
|
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Wired on this head although a phone has no right button, and not for symmetry: this head runs the same
|
||||||
|
/// renderer page, and a device with a mouse, a trackpad case or a desktop dock has a secondary click.
|
||||||
|
/// Whether Android's WebView delivers one as a DOM <c>contextmenu</c> event is unverified — see
|
||||||
|
/// <c>docs/manual-checks.md</c> 11.13 — and leaving this out in the meantime would make the paste half
|
||||||
|
/// the one thing that worked on one head and silently did nothing on the other.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static Func<Task<string?>> ClipboardReader(Visual surface) =>
|
||||||
|
async () => TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard }
|
||||||
|
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
|
||||||
|
: null;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
|
/// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
|
||||||
/// the platform graph, and below it is the shell every head shares.
|
/// the platform graph, and below it is the shell every head shares.
|
||||||
@@ -186,7 +212,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
TerminalWorkspace workspace,
|
TerminalWorkspace workspace,
|
||||||
VaultKnownHostStore knownHosts,
|
VaultKnownHostStore knownHosts,
|
||||||
SshNetConnectionFactory connections,
|
SshNetConnectionFactory connections,
|
||||||
Func<string, Task> copyToClipboard)
|
Func<string, Task> copyToClipboard,
|
||||||
|
Func<Task<string?>> readClipboard)
|
||||||
{
|
{
|
||||||
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
|
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
|
||||||
// key. A straight implementation of the interface the session layer has always taken.
|
// key. A straight implementation of the interface the session layer has always taken.
|
||||||
@@ -236,6 +263,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
deviceName: PhoneEnvironment.DeviceName,
|
deviceName: PhoneEnvironment.DeviceName,
|
||||||
|
|
||||||
copyToClipboard: copyToClipboard,
|
copyToClipboard: copyToClipboard,
|
||||||
|
readClipboard: readClipboard,
|
||||||
|
|
||||||
updates: updates);
|
updates: updates);
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ public sealed class MainActivity : AvaloniaMainActivity
|
|||||||
PhoneEnvironment.CurrentActivity = this;
|
PhoneEnvironment.CurrentActivity = this;
|
||||||
|
|
||||||
AndroidRedirectCallback.Abandon();
|
AndroidRedirectCallback.Abandon();
|
||||||
|
|
||||||
|
// And the keyboard may have gone away while this activity was not on screen, without Avalonia's
|
||||||
|
// input pane raising anything to say so. PhoneShell re-reads the inset from this. See
|
||||||
|
// PhoneEnvironment.Resumed, which carries the whole of why.
|
||||||
|
PhoneEnvironment.RaiseResumed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -100,6 +100,35 @@ internal static class PhoneEnvironment
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static global::Android.App.Activity? CurrentActivity { get; set; }
|
public static global::Android.App.Activity? CurrentActivity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised each time the launcher activity comes back to the foreground.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Exists because the software keyboard can go away without Avalonia being told.</b>
|
||||||
|
/// <c>IInputPane.StateChanged</c> is the only signal <c>PhoneShell</c> has for the keyboard opening and
|
||||||
|
/// closing, and it is not raised when Android dismisses the keyboard on the way out of the app — the
|
||||||
|
/// activity is being paused, and the pane is simply gone by the time anything could have reported it.
|
||||||
|
/// So the inset that holds the interface clear of the keyboard was still applied on the way back in,
|
||||||
|
/// and the whole phone drew an entire keyboard's height above the bottom of a screen with no keyboard
|
||||||
|
/// on it. Reproduced every time: open the keyboard anywhere, press Home, return — 838 of 2340 pixels
|
||||||
|
/// dead, and staying that way until the keyboard was opened and closed again.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// An event here rather than a call into the view, because the view is Avalonia's and this is Android's:
|
||||||
|
/// <c>MainActivity</c> already owns <c>OnResume</c> and already writes <see cref="CurrentActivity"/>
|
||||||
|
/// from it, and a static event is the same shape of hand-off one step further. Anything else that needs
|
||||||
|
/// to re-read platform state on the way back in can subscribe without <c>MainActivity</c> learning what
|
||||||
|
/// it is.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Subscribers are responsible for unsubscribing — this is a static event and holds whatever it is
|
||||||
|
/// given for the life of the process. <c>PhoneShell</c> attaches and detaches with its visual tree.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static event EventHandler? Resumed;
|
||||||
|
|
||||||
|
/// <summary>Announces that the launcher activity is foreground again.</summary>
|
||||||
|
internal static void RaiseResumed() => Resumed?.Invoke(null, EventArgs.Empty);
|
||||||
|
|
||||||
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
|
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
|
||||||
public static Context Require() =>
|
public static Context Require() =>
|
||||||
context ?? throw new InvalidOperationException(
|
context ?? throw new InvalidOperationException(
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using global::Android.Views;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
|
||||||
|
/// back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>This replaces <c>TerminalFocus</c>, and it is the opposite move.</b> That class handed Android's own
|
||||||
|
/// view focus <em>back</em> to the <c>WebView</c> after Avalonia's <c>DispatchTouchEvent</c> took it, because
|
||||||
|
/// the input connection the keyboard was attached to belonged to the page. Under ADR 0015 it no longer does:
|
||||||
|
/// <see cref="Views.TerminalInputSurface"/> is the input target, and it is an Avalonia control, so the focus
|
||||||
|
/// the platform requests for Avalonia's view after every handled touch is now the focus we want. The fight
|
||||||
|
/// ends by removing one of the contestants rather than by refereeing it faster.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Non-focusable, not non-interactive, and the distinction is the whole design.</b> Focusability governs
|
||||||
|
/// where keyboard and D-pad input go; it has nothing to do with touch delivery. A <c>WebView</c> with
|
||||||
|
/// <c>Focusable=false</c> still receives, scrolls and hit-tests every touch it is given — so the page keeps
|
||||||
|
/// its <c>touch-action: pan-y</c> scrollback exactly as before — but it can no longer take an input
|
||||||
|
/// connection, and Chromium's own <c>showSoftInput</c> for the focused DOM node cannot succeed from an
|
||||||
|
/// unfocused view. That is precisely the split wanted: the page keeps the finger, Avalonia keeps the
|
||||||
|
/// keyboard.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why a tap has to be reported at all.</b> With the page unable to raise a keyboard, the ordinary way
|
||||||
|
/// back after the system back gesture lowers one — touch the terminal — would otherwise do nothing. The
|
||||||
|
/// listener is attached with <c>Handled = false</c> throughout, so it observes without consuming: the
|
||||||
|
/// <c>WebView</c>'s own <c>OnTouchEvent</c> runs after it exactly as if nothing were listening.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A tap is distinguished from a scroll the way every Android view does it, by
|
||||||
|
/// <c>ViewConfiguration.ScaledTouchSlop</c>. Without that test, dragging the scrollback would raise the
|
||||||
|
/// keyboard over the very output being scrolled to.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
|
||||||
|
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
|
||||||
|
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
|
||||||
|
/// terminal. Every step is allowed to be absent — there is no activity while backgrounded, and no
|
||||||
|
/// <c>WebView</c> until the terminal surface has been shown once.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class RendererFocus
|
||||||
|
{
|
||||||
|
/// <summary>The view already configured, so that showing the terminal twice does not listen twice.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Weak, because this is a static holding an Android view: a strong reference would keep the whole view
|
||||||
|
/// hierarchy, and the activity behind it, alive for the life of the process.
|
||||||
|
/// </remarks>
|
||||||
|
private static WeakReference<global::Android.Webkit.WebView>? configured;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
|
||||||
|
/// taps it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
|
||||||
|
/// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is
|
||||||
|
/// free to tear its renderer down and build a new one, which is a case this head already handles
|
||||||
|
/// elsewhere (see <c>docs/android-port.md</c>).
|
||||||
|
/// </remarks>
|
||||||
|
public static void Suppress(Action onTapped)
|
||||||
|
{
|
||||||
|
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FindWebView(decor) is not { } webView)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configured is not null && configured.TryGetTarget(out var already) && ReferenceEquals(already, webView))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.Focusable = false;
|
||||||
|
webView.FocusableInTouchMode = false;
|
||||||
|
webView.ClearFocus();
|
||||||
|
|
||||||
|
WatchForTaps(webView, onTapped);
|
||||||
|
|
||||||
|
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A tap is a press and a release that did not travel further than the platform's own
|
||||||
|
/// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious:
|
||||||
|
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
|
||||||
|
/// </remarks>
|
||||||
|
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
|
||||||
|
{
|
||||||
|
// The fallback is the platform's own historical default, for a context that has somehow gone.
|
||||||
|
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
|
||||||
|
var downX = 0f;
|
||||||
|
var downY = 0f;
|
||||||
|
var moved = false;
|
||||||
|
|
||||||
|
webView.Touch += (_, e) =>
|
||||||
|
{
|
||||||
|
// Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
|
||||||
|
e.Handled = false;
|
||||||
|
|
||||||
|
if (e.Event is not { } motion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (motion.Action)
|
||||||
|
{
|
||||||
|
case MotionEventActions.Down:
|
||||||
|
downX = motion.GetX();
|
||||||
|
downY = motion.GetY();
|
||||||
|
moved = false;
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MotionEventActions.Move:
|
||||||
|
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MotionEventActions.Up when !moved:
|
||||||
|
onTapped();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < parent.ChildCount; i++)
|
||||||
|
{
|
||||||
|
switch (parent.GetChildAt(i))
|
||||||
|
{
|
||||||
|
case global::Android.Webkit.WebView webView:
|
||||||
|
return webView;
|
||||||
|
|
||||||
|
case ViewGroup child when FindWebView(child) is { } found:
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
using global::Android.Views;
|
|
||||||
|
|
||||||
namespace DodoSSH.Client.Android.Platform;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hands native focus back to the terminal's WebView after Avalonia chrome took it.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> the
|
|
||||||
/// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does
|
|
||||||
/// not own. The accessory row's keys are already <c>Focusable=false</c> — see TerminalScreen — so Avalonia's
|
|
||||||
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>:
|
|
||||||
/// <c>AvaloniaView.DispatchTouchEvent</c> (decompiled from Avalonia.Android 12.1.1) ends every handled
|
|
||||||
/// touch — DOWN and UP alike — with a <c>RequestFocus()</c> for Avalonia's own view. The WebView's input
|
|
||||||
/// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text,
|
|
||||||
/// and the inset churn that follows can leave it sitting on top of the very row that was tapped.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.</b>
|
|
||||||
/// The first version called <c>RequestFocus()</c> from the keys' own Click handlers, which fire
|
|
||||||
/// <em>inside</em> the UP event's dispatch — and the platform's own request runs <em>after</em> dispatch
|
|
||||||
/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
|
|
||||||
/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
|
|
||||||
/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
|
|
||||||
/// about to be made stale by the very mechanism this exists to counter.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining
|
|
||||||
/// native focus re-establishes the same input connection and the keyboard settles back to what it was.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, because the
|
|
||||||
/// control does not expose its platform child and this application only ever has the one WebView — the
|
|
||||||
/// walk's first match is necessarily the terminal. Every step is allowed to be absent, exactly as
|
|
||||||
/// <see cref="SoftKeyboard.Hide"/>'s are: no activity while backgrounded, no WebView while the terminal
|
|
||||||
/// surface has never been shown, and nothing to do in either case.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
internal static class TerminalFocus
|
|
||||||
{
|
|
||||||
public static void Return()
|
|
||||||
{
|
|
||||||
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FindWebView(decor) is not { } webView)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
webView.Post(() =>
|
|
||||||
{
|
|
||||||
if (!webView.IsFocused)
|
|
||||||
{
|
|
||||||
webView.RequestFocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static View? FindWebView(ViewGroup parent)
|
|
||||||
{
|
|
||||||
for (var i = 0; i < parent.ChildCount; i++)
|
|
||||||
{
|
|
||||||
switch (parent.GetChildAt(i))
|
|
||||||
{
|
|
||||||
case global::Android.Webkit.WebView webView:
|
|
||||||
return webView;
|
|
||||||
|
|
||||||
case ViewGroup child when FindWebView(child) is { } found:
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -167,10 +167,124 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||||
<Setter Property="FontSize" Value="20" />
|
<Setter Property="FontSize" Value="20" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<!--
|
||||||
|
◆ THE FILL BELOW IS NOT DECORATION. IT IS UNDOING ONE FLUENT PUTS THERE.
|
||||||
|
|
||||||
|
`accent` is not a name this theme invented — Fluent ships a `Button.accent` style of its own, and a
|
||||||
|
button classed `icon accent` matches it. So these three buttons were being painted with Fluent's
|
||||||
|
system-accent slab, #6275AC, a blue that appears nowhere in Palette.axaml and reads as a control from
|
||||||
|
a different application sitting in this one's header. Measured off a screenshot rather than guessed:
|
||||||
|
the fill was #6275AC where the accent is #5D42DE.
|
||||||
|
|
||||||
|
The rule above could not stop it, because it sets Foreground and FontSize and Fluent's fill is on the
|
||||||
|
ContentPresenter — which is also why `Button.icon`'s own Background="Transparent" does not reach it.
|
||||||
|
App.axaml has carried the desktop's answer to exactly this since v5b: override the ContentPresenter,
|
||||||
|
not the Button. This head simply never had one, so the desktop looked right and the phone did not.
|
||||||
|
|
||||||
|
Transparent rather than the desktop's gradient, and the difference is the control rather than an
|
||||||
|
inconsistency. The desktop's Button.accent is a filled primary slab — this head has one of those too,
|
||||||
|
and it is Button.primary. `icon accent` is a 44-pixel glyph in a header row beside other glyphs; what
|
||||||
|
"accent" asks for there is the accent *colour on the mark*, which is what the rule above gives it. A
|
||||||
|
slab would make one header icon a button and its neighbours not.
|
||||||
|
|
||||||
|
Pressed is deliberately not restated: Button.icon:pressed below sets ChromeHover on every icon button
|
||||||
|
and, coming later in this file, wins over this rule for the moment a finger is down. That is the
|
||||||
|
feedback the plain icon buttons give and there is no reason this one should differ.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.icon.accent /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.icon.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.icon.accent:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Button.icon:pressed /template/ ContentPresenter">
|
<Style Selector="Button.icon:pressed /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============ one key of the terminal's accessory keypad ============
|
||||||
|
|
||||||
|
A class rather than eleven sets of attributes assigned in code, and the move out of the code-behind is
|
||||||
|
most of what makes the latched state honest: Ctrl on and Ctrl off used to be two Background values
|
||||||
|
written by hand in TerminalScreen.axaml.cs, which meant the row named two of this palette's brushes
|
||||||
|
itself and any third state would have named a third. It is `latched` here, and the code toggles a class.
|
||||||
|
|
||||||
|
── the numbers, and why they are these ─────────────────────────────────────────────────────────────────
|
||||||
|
40 tall, where the row this replaced was 30. Android asks for 48 on a touch target and every terminal on
|
||||||
|
the platform lands near it; 30 was chosen to save a strip of the terminal above and took the keypad past
|
||||||
|
the point of working, which is the wrong end of that trade. 40 with a 2-pixel margin gives 44 of pitch,
|
||||||
|
which is the same figure TextBox.field uses for a box a thumb has to hit.
|
||||||
|
|
||||||
|
HorizontalAlignment Stretch, because these live in UniformGrid cells and the cell is the share of the
|
||||||
|
width the key is entitled to. Left to Center a key would shrink to its label and "|" would be a sliver
|
||||||
|
between two wide neighbours — which is the layout the flexed row exists to stop.
|
||||||
|
|
||||||
|
── pressed has to be visible, and it was not ───────────────────────────────────────────────────────────
|
||||||
|
These keys are Focusable="False" so that using one does not take the keyboard away from the terminal
|
||||||
|
(see TerminalScreen.axaml.cs), and a button that never takes focus also never shows the focus adorner
|
||||||
|
Fluent would otherwise give it. That left a press with no feedback at all on a control whose entire job
|
||||||
|
is to be pressed while somebody is looking at the line of output above it. The pressed fill below is the
|
||||||
|
only thing that says a key was hit — and on a keypad over a remote shell, "did that send?" is a question
|
||||||
|
worth answering in the interface rather than in the scrollback.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.hotkey">
|
||||||
|
<Setter Property="Height" Value="40" />
|
||||||
|
<Setter Property="MinHeight" Value="40" />
|
||||||
|
<Setter Property="MinWidth" Value="0" />
|
||||||
|
<Setter Property="Margin" Value="2,0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||||
|
</Style>
|
||||||
|
<!--
|
||||||
|
Latched Ctrl. The same fill a press gives, held rather than flashed — a modifier that is on and does not
|
||||||
|
look on is how somebody sends ^L to a database prompt believing they typed an l.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.hotkey.latched">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey.latched /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey:disabled">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The floating action button the design has asked for since v2, and which this theme refused twice — on
|
The floating action button the design has asked for since v2, and which this theme refused twice — on
|
||||||
the grounds that it would open an editor this head had not got, and that an unused style is a claim the
|
the grounds that it would open an editor this head had not got, and that an unused style is a claim the
|
||||||
@@ -398,6 +512,58 @@
|
|||||||
<Setter Property="FontSize" Value="12" />
|
<Setter Property="FontSize" Value="12" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ The two port boxes, which are NumericUpDowns and were the only unthemed controls on this head.
|
||||||
|
|
||||||
|
They sit in a two-column Grid with a plain TextBox.field beside them — port next to username in the host
|
||||||
|
editor, and port next to default-user in the group editor — so the mismatch was not subtle: a bright
|
||||||
|
Fluent border and a near-black fill against this theme's #101019 and hairline, at the one place on the
|
||||||
|
screen where the two are a hand's width apart and directly comparable.
|
||||||
|
|
||||||
|
Not `Classes="field"` on the two call sites, because that class is a TextBox class and this is not a
|
||||||
|
TextBox: NumericUpDown is a ButtonSpinner wrapping one, so half the chrome being corrected belongs to
|
||||||
|
the spinner and would not take a TextBox setter. Typing the control instead also means a third port box
|
||||||
|
added later is right without anybody remembering, which is the property the desktop's own bare
|
||||||
|
`Selector="NumericUpDown"` in App.axaml has had since it was written.
|
||||||
|
|
||||||
|
Three selectors rather than one for the same reason Button.icon.accent needs two: Fluent puts the fill
|
||||||
|
and the border on template parts, and a value set on the control alone is a TemplateBinding the theme's
|
||||||
|
own wins over. The numbers are TextBox.field's, exactly — 44 tall, 10 radius, Field on BorderMid — so
|
||||||
|
the pair reads as one row of two boxes rather than as a box and a visitor.
|
||||||
|
-->
|
||||||
|
<Style Selector="NumericUpDown">
|
||||||
|
<Setter Property="Height" Value="44" />
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="NumericUpDown /template/ ButtonSpinner">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
</Style>
|
||||||
|
<!--
|
||||||
|
The inner box gives its chrome up entirely: the spinner above is drawing the border and the fill, and a
|
||||||
|
TextBox that kept its own would put a second rounded rectangle a pixel inside the first.
|
||||||
|
-->
|
||||||
|
<Style Selector="NumericUpDown /template/ TextBox">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="14,0" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Every box on this head that takes a secret, and it is a class rather than two attributes repeated five
|
Every box on this head that takes a secret, and it is a class rather than two attributes repeated five
|
||||||
times because the two attributes are not interchangeable and both are needed.
|
times because the two attributes are not interchangeable and both are needed.
|
||||||
@@ -473,10 +639,29 @@
|
|||||||
<Setter Property="FontSize" Value="9.5" />
|
<Setter Property="FontSize" Value="9.5" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<!--
|
||||||
|
◆ Wraps, like .body below it, and the omission was doing real damage rather than none.
|
||||||
|
|
||||||
|
A TextBlock that neither wraps nor trims does not shrink to fit and does not report that it did not:
|
||||||
|
it lays out at its full single-line width and the parent clips whatever runs past the edge. So a
|
||||||
|
.detail long enough to overflow ended mid-word against the screen, with no ellipsis to say anything
|
||||||
|
was missing — "A machine to connect to, filed under a group if you" on the add sheet, which reads as
|
||||||
|
a sentence somebody forgot to finish rather than as text that was cut.
|
||||||
|
|
||||||
|
It is a style rather than an attribute on the ones that overflow today, because which of the fifty-odd
|
||||||
|
.detail blocks overflows is a function of the device's width and the user's font scale, not of the
|
||||||
|
string: every one of them is a candidate at 320dp that is fine at 411dp, and fixing the handful that
|
||||||
|
happen to be visibly wrong on a Pixel 5 would leave the rest to be found one narrow phone at a time.
|
||||||
|
|
||||||
|
The nine that genuinely want one line — list rows and log lines, where a wrapped detail would push the
|
||||||
|
row's own height around — now say TextWrapping="NoWrap" beside their TextTrimming, which is the pairing
|
||||||
|
that was always meant and was previously being had by accident.
|
||||||
|
-->
|
||||||
<Style Selector="TextBlock.detail">
|
<Style Selector="TextBlock.detail">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
<Setter Property="FontSize" Value="10" />
|
<Setter Property="FontSize" Value="10" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBlock.body">
|
<Style Selector="TextBlock.body">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
|||||||
@@ -551,7 +551,7 @@
|
|||||||
Foreground="{StaticResource Accent}"
|
Foreground="{StaticResource Accent}"
|
||||||
Background="{StaticResource Raised}" />
|
Background="{StaticResource Raised}" />
|
||||||
<TextBlock Classes="detail" Text="{Binding Progress}"
|
<TextBlock Classes="detail" Text="{Binding Progress}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -49,12 +49,29 @@
|
|||||||
|
|
||||||
<TextBlock Classes="title" Text="UNKNOWN HOST KEY" FontSize="13" />
|
<TextBlock Classes="title" Text="UNKNOWN HOST KEY" FontSize="13" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ The port and the full stop are one Run, and that is the whole of the fix.
|
||||||
|
|
||||||
|
Avalonia puts a space between adjacent Runs, which is what makes "First contact with", the
|
||||||
|
host and the separator read as a sentence without any of them carrying padding. The trailing
|
||||||
|
Run began with a full stop, so it got that space too and the sheet said
|
||||||
|
|
||||||
|
First contact with 10.0.2.2 · 2222 . Nothing in this keychain…
|
||||||
|
|
||||||
|
— a gap before the stop, on the one dialogue in this application whose entire job is to be
|
||||||
|
read carefully before somebody answers it. StringFormat welds the stop to the number inside a
|
||||||
|
single Run, where no inter-Run space can get in, and the sentence that follows starts a Run of
|
||||||
|
its own so it still gets the space it wants.
|
||||||
|
|
||||||
|
The `{}` prefix is required: a StringFormat beginning with a brace would otherwise be parsed
|
||||||
|
as a markup extension.
|
||||||
|
-->
|
||||||
<TextBlock Classes="body" Margin="0,6,0,0">
|
<TextBlock Classes="body" Margin="0,6,0,0">
|
||||||
<Run Text="First contact with" />
|
<Run Text="First contact with" />
|
||||||
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
||||||
<Run Text="·" />
|
<Run Text="·" />
|
||||||
<Run Text="{Binding PendingHostKey.Port}" />
|
<Run Text="{Binding PendingHostKey.Port, StringFormat='{}{0}.'}" />
|
||||||
<Run Text=". Nothing in this keychain has approved this key." />
|
<Run Text="Nothing in this keychain has approved this key." />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
|
|
||||||
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
|
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
|
||||||
|
|||||||
@@ -465,7 +465,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
|
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
|
||||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
|
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -287,7 +287,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<!-- What is known *about* an item, never the secret. -->
|
<!-- What is known *about* an item, never the secret. -->
|
||||||
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
|
<TextBlock Classes="detail" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||||
|
|||||||
@@ -106,11 +106,11 @@
|
|||||||
|
|
||||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
|
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
|
||||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
||||||
Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
|
Text="{Binding Address}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
TextDim rather than the `detail` class's own faint step. This block is drawn on
|
TextDim rather than the `detail` class's own faint step. This block is drawn on
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||||
Text="·" />
|
Text="·" />
|
||||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||||
Text="{Binding DeviceName}" TextTrimming="CharacterEllipsis" />
|
Text="{Binding DeviceName}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- A refused host key is the one outcome worth a colour of its own. -->
|
<!-- A refused host key is the one outcome worth a colour of its own. -->
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
||||||
Text="{Binding ItemLabel}" TextTrimming="CharacterEllipsis" />
|
Text="{Binding ItemLabel}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
<!-- The names of the fields that changed, never their values. -->
|
<!-- The names of the fields that changed, never their values. -->
|
||||||
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasChangedFields}"
|
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasChangedFields}"
|
||||||
|
|||||||
@@ -391,11 +391,15 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
ApplyKeyboardInset(keyboard);
|
ApplyKeyboardInset(keyboard);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PhoneEnvironment.Resumed += OnActivityResumed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
|
PhoneEnvironment.Resumed -= OnActivityResumed;
|
||||||
|
|
||||||
if (TopLevel.GetTopLevel(this) is { } top)
|
if (TopLevel.GetTopLevel(this) is { } top)
|
||||||
{
|
{
|
||||||
top.BackRequested -= OnBackRequested;
|
top.BackRequested -= OnBackRequested;
|
||||||
@@ -410,6 +414,43 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
base.OnDetachedFromVisualTree(e);
|
base.OnDetachedFromVisualTree(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-reads the keyboard inset on the way back into the application.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The margin can be stale here and there is no other moment it gets corrected.</b> Android takes
|
||||||
|
/// the software keyboard away when this activity stops, and Avalonia's input pane raises no
|
||||||
|
/// <c>StateChanged</c> for it — the pane is gone with the window rather than closed in front of one. So
|
||||||
|
/// <see cref="ApplyKeyboardInset(double)"/> last ran for a keyboard that was up, and the interface comes
|
||||||
|
/// back holding a whole keyboard's worth of empty space it no longer needs. It is not subtle and it does
|
||||||
|
/// not recover: a third of the display, on every screen, until something opens and closes the keyboard.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asking the pane rather than assuming zero, because resuming does not always mean the keyboard went.
|
||||||
|
/// A permission dialogue, the biometric sheet and the sign-in browser all pause this activity and can
|
||||||
|
/// all hand it back with the keyboard exactly as it was — and a handler that flattened the inset on the
|
||||||
|
/// way in would then strand the focused box under a keyboard that is genuinely there. The pane knows
|
||||||
|
/// which of the two happened; this only has to ask it again.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Posted rather than run inline. <c>OnResume</c> is early — the window has not necessarily been laid
|
||||||
|
/// out again and the pane's own state is settled by the insets that arrive with the next frame, so
|
||||||
|
/// reading it on this turn can return the state that is on the way out. Background priority puts the
|
||||||
|
/// read after that.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnActivityResumed(object? sender, EventArgs e) =>
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (keyboard is not null)
|
||||||
|
{
|
||||||
|
ApplyKeyboardInset(keyboard);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DispatcherPriority.Background);
|
||||||
|
|
||||||
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
||||||
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
|
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,8 @@
|
|||||||
Text="Text size is on the terminal itself, where the text is — the phone puts A− and A+ in the shell's own bar rather than a step away from what they resize." />
|
Text="Text size is on the terminal itself, where the text is — the phone puts A− and A+ in the shell's own bar rather than a step away from what they resize." />
|
||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." />
|
Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." />
|
||||||
|
<TextBlock Classes="body"
|
||||||
|
Text="Importing a Hetzner Cloud project would work here — it is a token and a network request, not a file — and the wizard behind it is already shared. What is missing is a phone layout for it: the desktop draws a five-column table, and the screen it would become is not the desktop's with narrower columns. Until then it is a thing to do on a desktop; the hosts it makes sync here like any other." />
|
||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
|
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One keystroke's worth of bytes, on its way from <see cref="TerminalInputSurface"/> to the shell.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Bytes rather than text, because half of what a terminal receives has no text form: a cursor key is an
|
||||||
|
/// escape sequence and Ctrl-C is a single byte below the printable range. Encoding at the surface — where
|
||||||
|
/// the key is still known — rather than downstream is what keeps the send path from having to guess which
|
||||||
|
/// kind it was handed.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="bytes">The encoded keystroke.</param>
|
||||||
|
internal sealed class TerminalInputEventArgs(byte[] bytes) : EventArgs
|
||||||
|
{
|
||||||
|
public byte[] Bytes { get; } = bytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Input.TextInput;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The terminal's keyboard, as an Avalonia control that draws nothing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>This exists so that the software keyboard is configured by us rather than by Chromium</b>, and that
|
||||||
|
/// single sentence is the whole of ADR 0015. A phone keyboard over a terminal has to be told to stop
|
||||||
|
/// autocorrecting, stop predicting, stop capitalising and stop offering a suggestion strip, and on Android
|
||||||
|
/// the only setting that actually achieves it is an <c>EditorInfo.inputType</c> carrying
|
||||||
|
/// <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD</c>. A page inside a <c>WebView</c> cannot reach that field —
|
||||||
|
/// Chromium derives the whole <c>EditorInfo</c> from the focused DOM node, and xterm.js already sets every
|
||||||
|
/// attribute the web platform offers (<c>autocorrect</c>, <c>autocapitalize</c>, <c>spellcheck</c>) to no
|
||||||
|
/// effect, because they are advisory and Gboard ignores them.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Avalonia can reach it, on the version this repository already pins.</b>
|
||||||
|
/// <c>AndroidInputMethod.OnCreateInputConnection</c> in Avalonia.Android 12.1.1 reads:
|
||||||
|
/// <code>
|
||||||
|
/// if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
|
||||||
|
/// outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
|
||||||
|
/// </code>
|
||||||
|
/// <c>0x80080</c> is <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS</c> — the exact
|
||||||
|
/// pair Termux's own <c>TerminalView</c> sets, and the reason typing in Termux feels like a terminal. The
|
||||||
|
/// guard is why the constructor leaves <c>ContentType</c>, <c>Multiline</c> and <c>AutoCapitalization</c>
|
||||||
|
/// alone: any of those would move <c>InputType</c> off <c>1</c> and the branch would never be taken.
|
||||||
|
/// <c>ShowSuggestions</c> is a <c>bool?</c> defaulting to <c>null</c>, so it must be set to <c>false</c>
|
||||||
|
/// rather than merely left.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Two options are set, and the second one is not optional either.</b> The default
|
||||||
|
/// <c>ReturnKeyType</c> maps to <c>IME_ACTION_DONE</c> on a non-multiline field, which closed the keyboard
|
||||||
|
/// on every Enter — that is, on every command. <c>ReturnKeyType.Return</c> is <c>IME_ACTION_NONE</c> and
|
||||||
|
/// touches nothing else. The constructor carries the full argument, including why the obvious alternative
|
||||||
|
/// of <c>Multiline</c> cannot be used without giving the suggestion strip back.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One pixel, and never drawn.</b> Focus in Avalonia has nothing to do with hit-testing, so the input
|
||||||
|
/// target does not need to cover the terminal — and must not, because the renderer is a native child view
|
||||||
|
/// composited above everything Avalonia paints, so a control stretched over it would be invisible, unable
|
||||||
|
/// to receive the touches the <c>WebView</c> consumes first, and in the way of the connect page that shares
|
||||||
|
/// this Panel when no session is open. A 1×1 control in the corner is focusable, costs one layout slot, and
|
||||||
|
/// is the whole of what an input target has to be. Taps on the terminal itself are noticed by
|
||||||
|
/// <see cref="Platform.RendererFocus"/> instead, which sees them where they actually land.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing is retained.</b> <see cref="TerminalImeClient"/> reports no surrounding text and no preedit,
|
||||||
|
/// because a terminal has no document for an input method to edit — the remote holds the line and the only
|
||||||
|
/// honest answer to "what is around the cursor" is "ask the host, which you cannot". Committed text is
|
||||||
|
/// turned into bytes and forwarded the instant it arrives.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class TerminalInputSurface : Control
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bytes the shell should receive, already encoded.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Raised rather than sent here, because the Ctrl latch belongs to the accessory row and the session to
|
||||||
|
/// the view model. <c>TerminalScreen.SendAsync</c> owns both and applies the latch to anything one byte
|
||||||
|
/// long, which is exactly the set a latch should reach: a typed letter, yes; a pasted line or a cursor
|
||||||
|
/// key's escape sequence, no.
|
||||||
|
/// </remarks>
|
||||||
|
internal event EventHandler<TerminalInputEventArgs>? Input;
|
||||||
|
|
||||||
|
private readonly TerminalImeClient client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the text event about to arrive has already been sent as a control byte.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Necessary because handling a key event does not stop the text that follows it.</b> Avalonia's
|
||||||
|
/// <c>AndroidKeyboardEventsHelper</c> raises the key event and then, independently, raises a text input
|
||||||
|
/// event for any key whose <c>UnicodeChar</c> is 32 or above — it reads the handled flag from the first
|
||||||
|
/// only to combine into its own return value, never to skip the second. So Ctrl-C on a hardware keyboard
|
||||||
|
/// produces both a <c>KeyDown</c> this class turns into <c>0x03</c> and a text input carrying a literal
|
||||||
|
/// <c>c</c>, and without this flag the shell receives an interrupt followed by a stray character.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A plain field rather than anything cleverer because the two events are raised one after the other in
|
||||||
|
/// the same dispatch, and it is cleared at the top of every <see cref="OnKeyDown"/> so a chord that
|
||||||
|
/// somehow produced no text cannot swallow the next thing typed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The keys in <see cref="TryTranslate"/> need no such guard — Enter, Tab, Escape and Backspace all
|
||||||
|
/// carry a <c>UnicodeChar</c> below 32, so no text event is raised for them in the first place.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private bool controlByteAlreadySent;
|
||||||
|
|
||||||
|
public TerminalInputSurface()
|
||||||
|
{
|
||||||
|
client = new TerminalImeClient(this);
|
||||||
|
|
||||||
|
Focusable = true;
|
||||||
|
|
||||||
|
// Drawn nowhere and hit-tested never — see the remarks. The size is 1 rather than 0 only because a
|
||||||
|
// zero-area control is a shape no layout pass has a reason to keep.
|
||||||
|
Width = 1;
|
||||||
|
Height = 1;
|
||||||
|
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left;
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top;
|
||||||
|
IsHitTestVisible = false;
|
||||||
|
|
||||||
|
// The one line that fixes the keyboard. See the remarks for why almost nothing else is set.
|
||||||
|
TextInputOptions.SetShowSuggestions(this, false);
|
||||||
|
|
||||||
|
// ◆ AND THE ONE THAT KEEPS THE KEYBOARD UP WHEN A COMMAND IS RUN.
|
||||||
|
//
|
||||||
|
// Avalonia maps ReturnKeyType.Default, on a field that is not Multiline, to IME_ACTION_DONE — and
|
||||||
|
// Done means done: the keyboard performs the action and dismisses itself. Every Enter therefore
|
||||||
|
// closed the keyboard, which on a terminal is every command.
|
||||||
|
//
|
||||||
|
// Return is the member that maps to IME_ACTION_NONE, leaving the key as a plain newline with no
|
||||||
|
// action attached and no reason for the IME to hide. Its whole switch arm is
|
||||||
|
// `returnKeyType - 1 => 0 => 1073741824`, which is 0x40000000, IME_ACTION_NONE.
|
||||||
|
//
|
||||||
|
// Multiline=true reaches the same IME_ACTION_NONE through the default arm and is the wrong way to
|
||||||
|
// get there: it also ORs TYPE_TEXT_FLAG_MULTI_LINE into InputType, which moves InputType off 1 and
|
||||||
|
// silently fails the ShowSuggestions guard above. The two options cannot both be had that way — one
|
||||||
|
// trades the suggestion strip back for a working Enter. ReturnKeyType costs nothing and is the
|
||||||
|
// reason this file sets it rather than Multiline.
|
||||||
|
TextInputOptions.SetReturnKeyType(this, TextInputReturnKeyType.Return);
|
||||||
|
|
||||||
|
AddHandler(TextInputMethodClientRequestedEvent, OnTextInputMethodClientRequested);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes focus and asks for the keyboard.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The request is made even when focus was already here, which is the case that matters: the system back
|
||||||
|
/// gesture puts the keyboard away without moving focus, so the way back has to be an activation request
|
||||||
|
/// rather than a focus change that would be a no-op. <c>AndroidInputMethod</c> answers
|
||||||
|
/// <c>InputPaneActivationRequested</c> with <c>ShowSoftInput</c>, which is precisely this.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Focusing for the first time raises the keyboard on its own — <c>SetClient</c> calls
|
||||||
|
/// <c>RestartInput</c> and <c>ShowSoftInput</c> when it takes a non-null client — so the ordering here is
|
||||||
|
/// belt and braces rather than two mechanisms disagreeing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal void Acquire()
|
||||||
|
{
|
||||||
|
Focus();
|
||||||
|
client.RequestKeyboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTextInputMethodClientRequested(object? sender, TextInputMethodClientRequestedEventArgs e) =>
|
||||||
|
e.Client = client;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything the IME commits and everything a hardware keyboard types that is not in
|
||||||
|
/// <see cref="TryTranslate"/>'s table. UTF-8 because that is what the pty is told the session is, and
|
||||||
|
/// because a multi-byte character has to arrive as one write or a partial sequence reaches the remote.
|
||||||
|
/// </remarks>
|
||||||
|
protected override void OnTextInput(TextInputEventArgs e)
|
||||||
|
{
|
||||||
|
if (controlByteAlreadySent)
|
||||||
|
{
|
||||||
|
controlByteAlreadySent = false;
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(e.Text))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newlines arrive as carriage returns, whichever way they came. A keyboard with no editor action
|
||||||
|
// attached may commit "\n" as ordinary text rather than sending KEYCODE_ENTER, and a paste carries
|
||||||
|
// whatever the clipboard held — but a terminal's line terminator from a keyboard is CR, and a shell
|
||||||
|
// handed LF for Enter is a shell that looks like it ignored the key. Normalised rather than special
|
||||||
|
// -cased so that a pasted "\r\n" does not run the line twice.
|
||||||
|
var text = e.Text.ReplaceLineEndings("\r");
|
||||||
|
|
||||||
|
Input?.Invoke(this, new TerminalInputEventArgs(Encoding.UTF8.GetBytes(text)));
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
base.OnTextInput(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Only the keys that have no text of their own are handled here; everything printable is left alone so
|
||||||
|
/// that it arrives once, through <see cref="OnTextInput"/>. Handling both would double every letter.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Ctrl with a letter is translated here rather than left to the latch, because a hardware keyboard can
|
||||||
|
/// hold a modifier and this is the only place that sees the chord.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
|
{
|
||||||
|
controlByteAlreadySent = false;
|
||||||
|
|
||||||
|
if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && TryControlChord(e.Key, out var chord))
|
||||||
|
{
|
||||||
|
Input?.Invoke(this, new TerminalInputEventArgs(chord));
|
||||||
|
|
||||||
|
// The letter's own text event is still coming; see the field's remarks.
|
||||||
|
controlByteAlreadySent = true;
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryTranslate(e.Key, out var bytes))
|
||||||
|
{
|
||||||
|
Input?.Invoke(this, new TerminalInputEventArgs(bytes));
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnKeyDown(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The ASCII rule and nothing cleverer, matching <c>TerminalScreen.SendAsync</c>'s latch: Ctrl-A through
|
||||||
|
/// Ctrl-Z are <c>0x01</c>–<c>0x1A</c>, which is the letter masked with <c>0x1F</c>.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool TryControlChord(Key key, out byte[] bytes)
|
||||||
|
{
|
||||||
|
if (key is >= Key.A and <= Key.Z)
|
||||||
|
{
|
||||||
|
bytes = [(byte)((byte)('a' + (key - Key.A)) & 0x1F)];
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes = [];
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keys a terminal expects as escape sequences rather than as text.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Cursor keys are written as CSI rather than SS3, which is the same choice — and the same reasoning —
|
||||||
|
/// as the accessory row's table in <c>TerminalScreen</c>: the difference between <c>CSI A</c> and
|
||||||
|
/// <c>SS3 A</c> is the difference between working in bash and not, and nothing here tracks the remote's
|
||||||
|
/// application-cursor mode.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b><see cref="Key.Delete"/> is deliberately spent on backspace, and it is the one lossy choice in
|
||||||
|
/// this file.</b> Avalonia's Android input connection turns an IME's <c>deleteSurroundingText</c> into a
|
||||||
|
/// dispatched <c>KEYCODE_FORWARD_DEL</c> — see <c>TextEditBuffer.Remove</c> — so a soft keyboard that
|
||||||
|
/// deletes through the edit buffer rather than through a real <c>KEYCODE_DEL</c> arrives here as
|
||||||
|
/// <c>Delete</c>. Mapping it to <c>CSI 3 ~</c> would be correct for a hardware forward-delete and would
|
||||||
|
/// make backspace delete forwards on any keyboard taking the other route, which is the worse failure by
|
||||||
|
/// a wide margin: a phone has no forward-delete key to miss, and a broken backspace makes the terminal
|
||||||
|
/// unusable. Revisit if a hardware keyboard's Delete is ever worth more than this insurance.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static bool TryTranslate(Key key, out byte[] bytes)
|
||||||
|
{
|
||||||
|
bytes = key switch
|
||||||
|
{
|
||||||
|
Key.Enter => [0x0D],
|
||||||
|
Key.Tab => [0x09],
|
||||||
|
Key.Escape => [0x1B],
|
||||||
|
Key.Back or Key.Delete => [0x7F],
|
||||||
|
|
||||||
|
Key.Up => [0x1B, (byte)'[', (byte)'A'],
|
||||||
|
Key.Down => [0x1B, (byte)'[', (byte)'B'],
|
||||||
|
Key.Right => [0x1B, (byte)'[', (byte)'C'],
|
||||||
|
Key.Left => [0x1B, (byte)'[', (byte)'D'],
|
||||||
|
|
||||||
|
Key.Home => [0x1B, (byte)'[', (byte)'H'],
|
||||||
|
Key.End => [0x1B, (byte)'[', (byte)'F'],
|
||||||
|
Key.Insert => [0x1B, (byte)'[', (byte)'2', (byte)'~'],
|
||||||
|
Key.PageUp => [0x1B, (byte)'[', (byte)'5', (byte)'~'],
|
||||||
|
Key.PageDown => [0x1B, (byte)'[', (byte)'6', (byte)'~'],
|
||||||
|
|
||||||
|
_ => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return bytes.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What Android's input method is told about the text it is editing, which is that there is none.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <c>SupportsSurroundingText</c> and <c>SupportsPreedit</c> are both false, and
|
||||||
|
/// <see cref="SurroundingText"/> is empty, because they are the truth: the line being edited lives on
|
||||||
|
/// the far side of an SSH channel and this client has never been told what is on it. Claiming otherwise
|
||||||
|
/// would invite the IME to reason about text it cannot see and to issue edits against positions that
|
||||||
|
/// mean nothing here.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Selection"/> has to be settable because <c>TextEditBuffer</c> writes it before every
|
||||||
|
/// replacement, so it is stored and ignored rather than refused — a property that threw would take the
|
||||||
|
/// input connection down with it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="CursorRectangle"/> is empty for the same reason the control is 1×1: it positions an IME's
|
||||||
|
/// composition popup, and a field that never composes has no popup to place.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class TerminalImeClient(Visual visual) : TextInputMethodClient
|
||||||
|
{
|
||||||
|
public override Visual TextViewVisual => visual;
|
||||||
|
|
||||||
|
public override bool SupportsPreedit => false;
|
||||||
|
|
||||||
|
public override bool SupportsSurroundingText => false;
|
||||||
|
|
||||||
|
public override string SurroundingText => string.Empty;
|
||||||
|
|
||||||
|
public override Rect CursorRectangle => default;
|
||||||
|
|
||||||
|
public override TextSelection Selection { get; set; }
|
||||||
|
|
||||||
|
/// <remarks>Exposed because <c>RaiseInputPaneActivationRequested</c> is protected on the base.</remarks>
|
||||||
|
internal void RequestKeyboard() => RaiseInputPaneActivationRequested();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -341,7 +341,7 @@
|
|||||||
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
|
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||||
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
|
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
|
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
|
||||||
VerticalAlignment="Center" Text="{Binding Started}" />
|
VerticalAlignment="Center" Text="{Binding Started}" />
|
||||||
@@ -471,6 +471,29 @@
|
|||||||
design's #171A26. See WebAssets/terminal.css.
|
design's #171A26. See WebAssets/terminal.css.
|
||||||
-->
|
-->
|
||||||
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============ the keyboard ============
|
||||||
|
|
||||||
|
One pixel in the corner, drawn never, and it is where every character typed into a shell on this
|
||||||
|
phone arrives. ADR 0015 has the whole argument; the short of it is that a software keyboard over a
|
||||||
|
terminal has to be told to stop autocorrecting, and on Android the only setting that works is an
|
||||||
|
inputType on a native input connection — which a page inside a WebView cannot reach and an Avalonia
|
||||||
|
control can, on the version already pinned here.
|
||||||
|
|
||||||
|
Not stretched over the renderer, which would be the obvious shape and is wrong three times: the
|
||||||
|
WebView composites above everything Avalonia paints so it would be invisible, it would never see the
|
||||||
|
touches the WebView consumes first, and it would sit over the connect page that shares this Panel
|
||||||
|
whenever no session is open. Focus does not need area. Taps on the terminal are noticed by
|
||||||
|
RendererFocus, where they actually land.
|
||||||
|
|
||||||
|
Collapsed with the renderer, on the same binding and for a reason of its own: a focusable control is
|
||||||
|
a tab stop and a focus candidate wherever it lives, and this Panel is also the connect page when no
|
||||||
|
session is open. Left always-visible it would be a stray target on a screen with a real text box on
|
||||||
|
it, offering a keyboard configured for a shell to somebody typing a hostname.
|
||||||
|
-->
|
||||||
|
<views:TerminalInputSurface x:Name="Input"
|
||||||
|
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- ============ accessory keys ============ -->
|
<!-- ============ accessory keys ============ -->
|
||||||
@@ -482,13 +505,50 @@
|
|||||||
-->
|
-->
|
||||||
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
||||||
<!--
|
<!--
|
||||||
33 tall rather than 50, a third off it like the bar at the top, with the keys inside coming down from
|
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
|
||||||
38 to 30 for the reason the pills did: a row that shrank around its own contents would clip them.
|
|
||||||
|
|
||||||
The five pixels above it are a gap and not a border. The renderer is a native child view, so nothing
|
What this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
|
||||||
Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its own —
|
and it was wrong in two ways that compounded each other.
|
||||||
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.
|
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
|
||||||
|
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
|
||||||
|
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
|
||||||
|
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
|
||||||
|
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
|
||||||
|
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
|
||||||
|
to be the first thing anybody noticed about this row.
|
||||||
|
|
||||||
|
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
|
||||||
|
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
|
||||||
|
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
|
||||||
|
which is a trade worth making right up until the control stops working, and 30 is past that.
|
||||||
|
|
||||||
|
── what the other clients do, because all of them agree ─────────────────────────────────────────────
|
||||||
|
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
|
||||||
|
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
|
||||||
|
second row — Termux ships two by default. That is not convention for its own sake: a key whose
|
||||||
|
position is fixed can be hit without looking, which is the entire point of a modifier row you use
|
||||||
|
while reading the output above it. A key that might have scrolled has to be found first.
|
||||||
|
|
||||||
|
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
|
||||||
|
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
|
||||||
|
ones that benefit, because the arrows were the keys living past the right edge.
|
||||||
|
|
||||||
|
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
|
||||||
|
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
|
||||||
|
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
|
||||||
|
Ctrl is not the better surface.
|
||||||
|
|
||||||
|
── the split ────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
|
||||||
|
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
|
||||||
|
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
|
||||||
|
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
|
||||||
|
|
||||||
|
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
|
||||||
|
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of
|
||||||
|
its own — and a terminal whose last line of output is flush against a row of grey keys reads as one
|
||||||
|
surface that has gone wrong rather than as two that are different things.
|
||||||
-->
|
-->
|
||||||
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||||
<!--
|
<!--
|
||||||
@@ -539,62 +599,70 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
|
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<StackPanel Spacing="4">
|
||||||
|
|
||||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
|
||||||
VerticalScrollBarVisibility="Disabled">
|
|
||||||
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
|
|
||||||
VerticalAlignment="Center" />
|
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
|
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
|
||||||
beside them.
|
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
|
||||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
"|" is one, and on a row of keys those two want the same target, not proportional ones.
|
||||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the state
|
|
||||||
in which hunting for the control that fixes it is worst. That argument is answered rather than
|
|
||||||
abandoned: outside the ScrollViewer they cannot scroll away, and the row they had costs 36 pixels
|
|
||||||
on a surface this change exists to give back.
|
|
||||||
|
|
||||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
|
||||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in this
|
|
||||||
screen.
|
|
||||||
|
|
||||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
|
||||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
|
||||||
never look like.
|
|
||||||
|
|
||||||
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
|
||||||
TerminalScreen.axaml.cs. Every button on this row sits over a terminal somebody is typing into,
|
|
||||||
and an ordinary one takes Avalonia's focus off the NativeWebView on tap, which makes the package
|
|
||||||
resign the page's focus. Making the text smaller must not cost the keyboard.
|
|
||||||
-->
|
-->
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
|
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
|
||||||
VerticalAlignment="Center">
|
|
||||||
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
|
|
||||||
VerticalAlignment="Center" />
|
|
||||||
|
|
||||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
|
<!--
|
||||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
The cursor keys, and the two text-size buttons riding at the end.
|
||||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
|
||||||
BorderThickness="1" Focusable="False"
|
|
||||||
Command="{Binding ShrinkTerminalFontCommand}"
|
|
||||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
|
||||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
|
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
|
||||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
|
||||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
|
||||||
BorderThickness="1" Focusable="False"
|
them every time the row's contents changed.
|
||||||
Command="{Binding EnlargeTerminalFontCommand}"
|
-->
|
||||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
||||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
|
||||||
</Button>
|
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
<!--
|
||||||
|
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>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
using DodoSSH.Client.Android.Platform;
|
using DodoSSH.Client.Android.Platform;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
@@ -37,7 +40,14 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
/// keys to fit 360dp produces ten targets too small to hit.
|
/// keys to fit 360dp produces ten targets too small to hit.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static readonly (string Label, byte[] Bytes, bool Latches)[] Keys =
|
/// <summary>The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Seven, which is what the row divides between — see the keypad's remark in TerminalScreen.axaml for
|
||||||
|
/// why the set is fixed and flexed rather than scrollable. Adding an eighth is a matter of adding it
|
||||||
|
/// here and changing the <c>Columns</c> on the UniformGrid; the two numbers have to agree, and the row
|
||||||
|
/// simply gets narrower keys.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly (string Label, byte[] Bytes, bool Latches)[] ModifierKeys =
|
||||||
[
|
[
|
||||||
("esc", [0x1B], false),
|
("esc", [0x1B], false),
|
||||||
("tab", [0x09], false),
|
("tab", [0x09], false),
|
||||||
@@ -46,12 +56,31 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
("-", [(byte)'-'], false),
|
("-", [(byte)'-'], false),
|
||||||
("~", [(byte)'~'], false),
|
("~", [(byte)'~'], false),
|
||||||
("/", [(byte)'/'], false),
|
("/", [(byte)'/'], false),
|
||||||
|
];
|
||||||
|
|
||||||
// The cursor keys, as the ANSI sequences a PTY expects. Written out rather than composed, because
|
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
|
||||||
// the difference between CSI A and SS3 A is the difference between working in bash and not.
|
/// <remarks>
|
||||||
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
/// <para>
|
||||||
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
/// 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)'D'], false),
|
||||||
|
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
||||||
|
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
||||||
("→", [0x1B, (byte)'[', (byte)'C'], false),
|
("→", [0x1B, (byte)'[', (byte)'C'], false),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -67,26 +96,119 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
|
|
||||||
private Button? controlKey;
|
private Button? controlKey;
|
||||||
|
|
||||||
|
private readonly TerminalInputSurface input;
|
||||||
|
|
||||||
|
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
|
||||||
|
private MainWindowViewModel? subscribed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The keyboard is raised on the transition into the terminal and not on every notification of it.
|
||||||
|
/// <c>IsTerminalShowing</c> is a computed property re-raised by several other setters, so acting on the
|
||||||
|
/// notification rather than the edge would call <c>ShowSoftInput</c> repeatedly for one arrival.
|
||||||
|
/// </remarks>
|
||||||
|
private bool terminalWasShowing;
|
||||||
|
|
||||||
public TerminalScreen()
|
public TerminalScreen()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
BuildAccessoryRow();
|
BuildAccessoryRow();
|
||||||
|
|
||||||
|
input = this.FindControl<TerminalInputSurface>("Input")!;
|
||||||
|
input.Input += (_, e) => SendAsync(e.Bytes);
|
||||||
|
|
||||||
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
||||||
|
|
||||||
renderer.EnvironmentRequested += OnRendererEnvironmentRequested;
|
renderer.EnvironmentRequested += (sender, e) =>
|
||||||
|
{
|
||||||
|
OnRendererEnvironmentRequested(sender, e);
|
||||||
|
|
||||||
|
// The precise moment the platform WebView is being built, which is the earliest one at which
|
||||||
|
// there is a native view to take the keyboard away from — and the one that fires again if
|
||||||
|
// Android reclaims the renderer and the page comes back. Posted rather than called, because the
|
||||||
|
// child is not in the hierarchy yet while its environment is still being asked for.
|
||||||
|
Dispatcher.UIThread.Post(AdoptRenderer, DispatcherPriority.Loaded);
|
||||||
|
};
|
||||||
|
|
||||||
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
|
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
|
||||||
DataContextChanged += (_, _) =>
|
DataContextChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
|
if (subscribed is { } previous)
|
||||||
|
{
|
||||||
|
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||||
|
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||||
|
subscribed = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (DataContext is MainWindowViewModel shell)
|
if (DataContext is MainWindowViewModel shell)
|
||||||
{
|
{
|
||||||
renderer.Source = shell.TerminalPageUrl;
|
renderer.Source = shell.TerminalPageUrl;
|
||||||
|
|
||||||
|
shell.PropertyChanged += OnShellPropertyChanged;
|
||||||
|
|
||||||
|
// The snippets screen's "TYPE INTO {tab}" lands here as well as on the desktop. It types at
|
||||||
|
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
|
||||||
|
// they look. See MainWindowViewModel.TerminalFocusRequested.
|
||||||
|
shell.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||||
|
subscribed = shell;
|
||||||
|
|
||||||
|
terminalWasShowing = shell.IsTerminalShowing;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
|
||||||
|
/// </summary>
|
||||||
|
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName is not nameof(MainWindowViewModel.IsTerminalShowing) ||
|
||||||
|
sender is not MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var showing = shell.IsTerminalShowing;
|
||||||
|
|
||||||
|
if (showing == terminalWasShowing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalWasShowing = showing;
|
||||||
|
|
||||||
|
if (!showing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posted to Loaded so the renderer's own native child, which becomes visible on this same change,
|
||||||
|
// exists by the time AdoptRenderer goes looking for it. Idempotent with the EnvironmentRequested
|
||||||
|
// path above: whichever runs first configures the view and the other finds it already done.
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
AdoptRenderer();
|
||||||
|
input.Acquire();
|
||||||
|
},
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTerminalFocusRequested(object? sender, EventArgs e) =>
|
||||||
|
Dispatcher.UIThread.Post(input.Acquire, DispatcherPriority.Loaded);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the keyboard off the renderer and puts the taps on it to use.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
|
||||||
|
/// the tap has to be reported rather than left to the page.
|
||||||
|
/// </remarks>
|
||||||
|
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Puts the keyboard away on the way to the connect menu.
|
/// Puts the keyboard away on the way to the connect menu.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -137,18 +259,26 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
|
|
||||||
private void BuildAccessoryRow()
|
private void BuildAccessoryRow()
|
||||||
{
|
{
|
||||||
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
// ── nothing to hand back any more ──────────────────────────────────────────────────────────────
|
||||||
|
// This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
|
||||||
|
// the platform requests Android's own focus for Avalonia's view after dispatching each handled
|
||||||
|
// touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
|
||||||
|
// TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
|
||||||
|
// the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
|
||||||
|
// stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
|
||||||
|
|
||||||
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
|
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
|
||||||
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
|
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
|
||||||
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
|
}
|
||||||
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
|
|
||||||
// length of the press; and the Return is posted past the current dispatch, so its ordering against
|
|
||||||
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
|
|
||||||
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
|
||||||
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
|
||||||
|
|
||||||
foreach (var (label, bytes, latches) in Keys)
|
/// <summary>Puts one table's worth of keys into one row of the keypad.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The rows differ in what they hold and in nothing else — same control, same class, same wiring — so
|
||||||
|
/// they are filled by one method rather than by two that would drift.
|
||||||
|
/// </remarks>
|
||||||
|
private void Fill(UniformGrid row, (string Label, byte[] Bytes, bool Latches)[] keys)
|
||||||
|
{
|
||||||
|
foreach (var (label, bytes, latches) in keys)
|
||||||
{
|
{
|
||||||
var key = CreateKey(label);
|
var key = CreateKey(label);
|
||||||
|
|
||||||
@@ -171,49 +301,31 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
private static Button CreateKey(string label) =>
|
private static Button CreateKey(string label) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Content = new TextBlock
|
Content = label,
|
||||||
{
|
|
||||||
Text = label,
|
|
||||||
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
|
|
||||||
FontSize = 11,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Center,
|
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
|
||||||
},
|
|
||||||
|
|
||||||
// 44 wide, and that is the number that matters: the design draws these flexed across the
|
// ◆ Every dimension and brush this used to set by hand is now Button.hotkey in Phone.axaml,
|
||||||
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
|
// and the move is what lets the row have a pressed state at all: a style can say :pressed and
|
||||||
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
|
// an assignment cannot. It also takes this file out of the business of naming the palette's
|
||||||
// costs nothing a width does: the keys are a single row with the terminal above and the
|
// brushes, which is how a keypad ends up with a grey nothing else uses.
|
||||||
// system's gesture bar below, so there is no neighbour a short press can land on instead.
|
//
|
||||||
MinWidth = 44,
|
// Content is the string rather than a TextBlock built here for the same reason — the class
|
||||||
Height = 30,
|
// carries the font and the alignment, so a wrapper element would only be somewhere for them to
|
||||||
Padding = new Thickness(10, 0),
|
// disagree.
|
||||||
CornerRadius = new CornerRadius(9),
|
Classes = { "hotkey" },
|
||||||
Background = Palette("Panel"),
|
|
||||||
BorderBrush = Palette("BorderMid"),
|
|
||||||
BorderThickness = new Thickness(1),
|
|
||||||
Foreground = Palette("TextDim"),
|
|
||||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
|
||||||
|
|
||||||
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
// ◆ 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
|
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
||||||
// ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView
|
// ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
|
||||||
// — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing
|
// terminal's input target is — and a modifier row that dismisses the keyboard every time it
|
||||||
// Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next
|
// is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
|
||||||
// thing typed on a hardware keyboard went nowhere, and the row went on working because its
|
// the focused element never changes: TerminalInputSurface is still it, so the input
|
||||||
// buttons are pressed rather than typed into, which is what makes it look like the terminal
|
// connection is never torn down and nothing has to be handed back.
|
||||||
// had died instead.
|
|
||||||
//
|
//
|
||||||
// Focusable=false is what a toolbar button is, and it means the focused element never
|
// This used to be only half a fix, because the target was the NativeWebView and Android keeps
|
||||||
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
// 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
|
||||||
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
|
// Avalonia control, so the focus Android moves is already where the keyboard should be.
|
||||||
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
|
|
||||||
// decides about its element — taking the keyboard's input connection off the terminal and
|
|
||||||
// swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
|
|
||||||
// that half back; see TerminalFocus for the whole story, including why the handing back has
|
|
||||||
// to be posted rather than done inline.
|
|
||||||
Focusable = false,
|
Focusable = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,18 +340,12 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
|
|
||||||
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
|
// 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.
|
// ^L to a database prompt believing they typed an l.
|
||||||
controlKey.Background = Palette(controlLatched ? "Active" : "Panel");
|
//
|
||||||
controlKey.Foreground = Palette(controlLatched ? "AccentText" : "TextDim");
|
// A class rather than two brush assignments, so what "latched" looks like is stated once in
|
||||||
|
// Phone.axaml beside what "pressed" looks like — which is where somebody changing either will be.
|
||||||
|
controlKey.Classes.Set("latched", controlLatched);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One brush from <c>Theme/Palette.axaml</c>, by key.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The accessory row is built in code because its keys come from a table, so its colours cannot be set in
|
|
||||||
/// XAML with the rest of the screen's. Resolving them by name is the next best thing: a row that named
|
|
||||||
/// its own blues is how the palette ends up with a fifth surface nobody meant to add.
|
|
||||||
/// </remarks>
|
|
||||||
private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush;
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
|
/// 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
|
/// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a
|
||||||
|
|||||||
@@ -220,7 +220,7 @@
|
|||||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
|
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ The one fact on this row that decides whether the button at the foot of the screen
|
◆ The one fact on this row that decides whether the button at the foot of the screen
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Input.Platform;
|
using Avalonia.Input.Platform;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
using DodoSSH.Client.App.Platform;
|
using DodoSSH.Client.App.Platform;
|
||||||
using DodoSSH.Client.App.Views;
|
using DodoSSH.Client.App.Views;
|
||||||
using DodoSSH.Client.Auth;
|
using DodoSSH.Client.Auth;
|
||||||
@@ -33,12 +34,77 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
Compose(desktop);
|
ShowSomethingWhileComposing(desktop);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts a window on screen first, and composes the application behind it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The splash <em>is</em> the main window for a moment, which is what makes this work at all.</b> The
|
||||||
|
/// desktop lifetime shows whatever <c>MainWindow</c> holds once this method returns and then runs the
|
||||||
|
/// message loop; a window created here and merely shown would be a window with no lifetime attached, and
|
||||||
|
/// with <c>ShutdownMode.OnLastWindowClose</c> the first close of anything would end the process. Handing
|
||||||
|
/// the property over is one assignment, and Avalonia reads it lazily.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>And composition is posted rather than called.</b> It is synchronous and takes long enough to be
|
||||||
|
/// worth a splash in the first place — which is the whole argument for this method — so running it here
|
||||||
|
/// would block the very dispatcher turn that has to paint the thing. Background priority puts it behind
|
||||||
|
/// rendering, which is what makes this a painted window rather than merely a shown one.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void ShowSomethingWhileComposing(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var splash = new SplashWindow();
|
||||||
|
|
||||||
|
desktop.MainWindow = splash;
|
||||||
|
|
||||||
|
// Fully qualified, and it has to be: Application inherits an instance Dispatcher property from
|
||||||
|
// AvaloniaObject, member lookup beats the namespace, and a static method here cannot reach an
|
||||||
|
// instance property — so the unqualified name is a compile error rather than the type. Same below.
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.Post(
|
||||||
|
() => ComposeBehind(desktop, splash),
|
||||||
|
DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The order at the end is not interchangeable. <c>ShutdownMode</c> is <c>OnLastWindowClose</c>, so the
|
||||||
|
/// real window is shown while the splash is still open and the splash is closed afterwards; closing the
|
||||||
|
/// only window there is would end the process instead of handing it over.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A failure is caught rather than left to the dispatcher, and the splash is exactly why.</b> Before
|
||||||
|
/// this method existed a composition that threw took the process with it and left nothing behind. Now
|
||||||
|
/// there is something to leave behind — so an exception escaping a posted callback would abandon a window
|
||||||
|
/// saying "Starting…" over a process that had stopped, which is worse than the crash it replaced. What
|
||||||
|
/// arrives instead is the sentence and a way to close it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void ComposeBehind(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
|
SplashWindow splash)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Compose(desktop);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
splash.CouldNotStart(exception.Message);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
desktop.MainWindow?.Show();
|
||||||
|
splash.Close();
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
|
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
|
||||||
@@ -72,6 +138,17 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Reads this machine's clipboard.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The other direction, looked up per call for the same reason and with the same fall-through. One
|
||||||
|
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
|
||||||
|
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
|
||||||
|
/// </remarks>
|
||||||
|
private static Func<Task<string?>> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) =>
|
||||||
|
async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard }
|
||||||
|
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
|
||||||
|
: null;
|
||||||
|
|
||||||
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
|
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
|
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
|
||||||
@@ -126,12 +203,44 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
|
|
||||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||||
|
|
||||||
|
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
||||||
|
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
||||||
|
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
||||||
|
var others = SingleInstance.Listen(
|
||||||
|
paths,
|
||||||
|
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
|
||||||
|
|
||||||
// Started rather than awaited: the framework's initialisation must not block on a schema
|
// Started rather than awaited: the framework's initialisation must not block on a schema
|
||||||
// migration. The view model shows its own progress and handles its own failures, which is why
|
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||||
// discarding the task here is safe rather than merely convenient.
|
// discarding the task here is safe rather than merely convenient.
|
||||||
_ = viewModel.StartAsync(CancellationToken.None);
|
_ = viewModel.StartAsync(CancellationToken.None);
|
||||||
|
|
||||||
WireShutdown(desktop, viewModel, workspace, caches);
|
WireShutdown(desktop, viewModel, workspace, caches, others);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the window somebody has just asked for by launching the application again.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Un-minimised first, because activating a minimised window leaves it minimised and the launch would
|
||||||
|
/// look ignored. <c>Activate</c> is a request rather than a guarantee — Windows refuses the foreground to
|
||||||
|
/// a process that did not receive the input, and what happens then is a flashing taskbar button, which is
|
||||||
|
/// still an answer. That is the platform's own rule about focus stealing and not a thing to work around.
|
||||||
|
/// </remarks>
|
||||||
|
private static void BringToFront(Window? window)
|
||||||
|
{
|
||||||
|
if (window is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.WindowState is WindowState.Minimized)
|
||||||
|
{
|
||||||
|
window.WindowState = WindowState.Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Show();
|
||||||
|
window.Activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
|
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
|
||||||
@@ -169,6 +278,7 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
|
|
||||||
copyToClipboard: ClipboardWriter(desktop),
|
copyToClipboard: ClipboardWriter(desktop),
|
||||||
|
readClipboard: ClipboardReader(desktop),
|
||||||
updates: updates,
|
updates: updates,
|
||||||
openInEditor: OpenInDefaultEditorAsync,
|
openInEditor: OpenInDefaultEditorAsync,
|
||||||
watchFile: WatchFile);
|
watchFile: WatchFile);
|
||||||
@@ -259,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
IClassicDesktopStyleApplicationLifetime desktop,
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
MainWindowViewModel viewModel,
|
MainWindowViewModel viewModel,
|
||||||
TerminalWorkspace workspace,
|
TerminalWorkspace workspace,
|
||||||
ClientCacheFactory caches)
|
ClientCacheFactory caches,
|
||||||
|
IAsyncDisposable others)
|
||||||
{
|
{
|
||||||
var shuttingDown = false;
|
var shuttingDown = false;
|
||||||
|
|
||||||
@@ -278,6 +389,11 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
await viewModel.DisposeAsync().ConfigureAwait(true);
|
await viewModel.DisposeAsync().ConfigureAwait(true);
|
||||||
await workspace.DisposeAsync().ConfigureAwait(true);
|
await workspace.DisposeAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
|
// Awaited rather than dropped, so the pipe name is free before this process is. It matters for
|
||||||
|
// one caller and matters a lot there: an update relaunches this application, and a successor
|
||||||
|
// that found the name still taken would be a second instance refusing itself.
|
||||||
|
await others.DisposeAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
caches.Dispose();
|
caches.Dispose();
|
||||||
|
|
||||||
desktop.Shutdown();
|
desktop.Shutdown();
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One desktop instance per profile, and a way for a second launch to raise the first.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Per profile rather than per machine, which decides two things at once.</b> The lock lives in
|
||||||
|
/// <see cref="ClientPaths.InstanceLockFile"/>, so a nightly and a release build never exclude each other —
|
||||||
|
/// they are separate applications by design, ADR 0013 — and two people signed in to one machine each get
|
||||||
|
/// their own instance. A global mutex would have had to re-derive both of those from somewhere.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why one instance at all.</b> The cache is a SQLite file with an outbox in it, and the whole design
|
||||||
|
/// assumes one writer: <see cref="ClientPaths"/> says so about two machines sharing a profile through a
|
||||||
|
/// cloud sync client, and two processes on one machine is the same hazard with a shorter path to it. The
|
||||||
|
/// terminal's loopback listener is the visible half — the second process fails to bind, gets a port of its
|
||||||
|
/// own or refuses to start a workspace at all — and the cache is the half that would quietly corrupt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A file handle rather than a named mutex</b>, because the operating system releases it when the
|
||||||
|
/// process ends <em>however</em> it ends. A named mutex abandoned by a killed process is a state both
|
||||||
|
/// platforms model differently and neither models simply; an exclusive handle is closed by the kernel on
|
||||||
|
/// Windows and by the file descriptor going away on Unix, so there is no such thing as a stale lock here.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>And a named pipe for the other half, because exiting silently is not an answer.</b> Somebody who
|
||||||
|
/// double-clicks the icon and sees nothing happen has been told the application is broken. The second
|
||||||
|
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
||||||
|
/// what makes the exclusion invisible rather than mysterious.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class SingleInstance
|
||||||
|
{
|
||||||
|
/// <summary>The one thing a second launch has to say.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared against a constant rather than parsed, for the reason <c>MainWindow</c>'s release-focus
|
||||||
|
/// message is: one message exists and treating anything else as a command would be a wider door than
|
||||||
|
/// this needs. Anything may connect to a per-user pipe — it is not a security boundary — so what arrives
|
||||||
|
/// is checked rather than trusted.
|
||||||
|
/// </remarks>
|
||||||
|
private const string ComeForward = "dodossh.come-forward";
|
||||||
|
|
||||||
|
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
||||||
|
/// not a bound on anything slow; it is how long to wait before concluding that whoever holds the lock
|
||||||
|
/// cannot be talked to.
|
||||||
|
/// </remarks>
|
||||||
|
private const int AnswerTimeoutMilliseconds = 500;
|
||||||
|
|
||||||
|
/// <summary>How long to keep trying for a lock that is held by nobody who answers.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>This is the window an update relaunch lands in, and without it an update would look like a
|
||||||
|
/// crash.</b> Velopack's apply ends this process and its updater then starts the new build; the two are
|
||||||
|
/// ordered — the updater waits on the old process id — but the whole sequence is one where a lock held a
|
||||||
|
/// moment too long would make the relaunched application exit on the spot, having apparently uninstalled
|
||||||
|
/// itself. The same window covers an ordinary double-double-click, where the first process holds the file
|
||||||
|
/// and has not reached its pipe yet.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Only reached when nobody answers the pipe. A live instance replies in milliseconds and the second
|
||||||
|
/// launch is gone before any of this matters.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly TimeSpan DefaultPatience = TimeSpan.FromSeconds(3);
|
||||||
|
|
||||||
|
/// <summary>How long to wait between attempts within that window.</summary>
|
||||||
|
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Claims this profile for this process, or asks whoever holds it to come forward.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// A handle to hold for the life of the process, or <see langword="null"/> when another instance has it —
|
||||||
|
/// in which case that instance has already been asked to raise its window and this process should end.
|
||||||
|
/// </returns>
|
||||||
|
/// <param name="paths">The profile to claim. Its directory is created if it is not there.</param>
|
||||||
|
/// <param name="patience">
|
||||||
|
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
||||||
|
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
||||||
|
/// </param>
|
||||||
|
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
paths.EnsureCreated();
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// No profile directory means no lock file, and every build before this one started anyway. What
|
||||||
|
// follows will fail on its own terms and say something better than this could.
|
||||||
|
return NoClaim.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counted rather than clocked. Thread.Sleep is not a clock and reading one here would be pretending
|
||||||
|
// otherwise — what this bounds is the number of attempts, and the wall time is whatever the platform
|
||||||
|
// makes of them.
|
||||||
|
var attemptsLeft = (int)Math.Ceiling(
|
||||||
|
(patience ?? DefaultPatience).TotalMilliseconds / RetryDelay.TotalMilliseconds);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (TryTake(paths.InstanceLockFile) is { } taken)
|
||||||
|
{
|
||||||
|
return taken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
||||||
|
// round trip on a local pipe rather than the whole window below.
|
||||||
|
if (AskTheRunningOneToComeForward(paths))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attemptsLeft-- <= 0)
|
||||||
|
{
|
||||||
|
// Held by something that will not talk. Giving up rather than starting anyway, because what
|
||||||
|
// is being protected is a database with one writer, and "probably nobody" is not an answer
|
||||||
|
// worth corrupting it for.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(RetryDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Answers the launches that were refused, for as long as the returned handle is held.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <paramref name="comeForward"/> is invoked on a thread pool thread, because that is where a pipe
|
||||||
|
/// accept completes. A caller that touches a window has to marshal.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="paths">The profile this instance claimed.</param>
|
||||||
|
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
||||||
|
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
ArgumentNullException.ThrowIfNull(comeForward);
|
||||||
|
|
||||||
|
var listener = new Doorbell(PipeNameFor(paths), comeForward);
|
||||||
|
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
return listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
|
||||||
|
/// <returns>Whether anything was listening.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
||||||
|
/// and proves itself, and this is the part with a protocol in it.
|
||||||
|
/// </remarks>
|
||||||
|
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var pipe = new NamedPipeClientStream(
|
||||||
|
serverName: ".",
|
||||||
|
PipeNameFor(paths),
|
||||||
|
PipeDirection.Out);
|
||||||
|
|
||||||
|
pipe.Connect(AnswerTimeoutMilliseconds);
|
||||||
|
|
||||||
|
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
||||||
|
|
||||||
|
writer.WriteLine(ComeForward);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
when (exception is TimeoutException or IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Nothing there, or nothing willing. The caller decides what that means; here it is only ever
|
||||||
|
// "no".
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
||||||
|
/// tidier and is worse: it makes the file's existence part of the protocol, so a second process opening
|
||||||
|
/// it while the first is closing gets a handle to something on its way out. An empty file that is always
|
||||||
|
/// there and sometimes locked has one fewer state.
|
||||||
|
/// </remarks>
|
||||||
|
private static IDisposable? TryTake(string lockFile)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new FileStream(
|
||||||
|
lockFile,
|
||||||
|
FileMode.OpenOrCreate,
|
||||||
|
FileAccess.ReadWrite,
|
||||||
|
FileShare.None,
|
||||||
|
bufferSize: 1,
|
||||||
|
FileOptions.None);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// Somebody else holds it. The one expected failure, and the whole point.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// A profile this user cannot write to, which is a broken installation rather than a second
|
||||||
|
// instance. Answered as a claim so the application starts and fails on something legible.
|
||||||
|
return NoClaim.Instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The pipe both sides name, derived from the profile so that it is scoped exactly as the lock is.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A digest of the path rather than the path itself: a pipe name may not contain a directory separator
|
||||||
|
/// on Windows, and on Unix the name becomes a file in a temporary directory whose length is bounded.
|
||||||
|
/// Eight bytes of SHA-256, which is not doing security work — it is making one string out of another
|
||||||
|
/// deterministically, and <c>string.GetHashCode</c> cannot because it is randomised per process.
|
||||||
|
/// </remarks>
|
||||||
|
private static string PipeNameFor(ClientPaths paths)
|
||||||
|
{
|
||||||
|
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(paths.DataDirectory));
|
||||||
|
|
||||||
|
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A claim over nothing, for the cases where there was nothing to claim.</summary>
|
||||||
|
private sealed class NoClaim : IDisposable
|
||||||
|
{
|
||||||
|
internal static NoClaim Instance { get; } = new();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
// Nothing was taken.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The accept loop that answers refused launches.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One server instance for the life of the doorbell, disconnected between callers rather than disposed
|
||||||
|
/// and rebuilt. The loop is what makes a third and fourth launch work as well as the second.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>THE ONE INSTANCE IS THE WHOLE CORRECTNESS OF THIS CLASS ON LINUX, AND A FRESH ONE PER CALLER
|
||||||
|
/// LOSES LAUNCHES.</b> This was written the obvious way — a <c>using</c> inside the loop, a new stream
|
||||||
|
/// per connection — which is fine on Windows and quietly wrong on Unix, where a named pipe is a Unix
|
||||||
|
/// domain socket. Disposing the server there closes the <em>listening</em> socket and unlinks its path,
|
||||||
|
/// so a second launch that connected while the first was being served has its connection dropped on the
|
||||||
|
/// floor: its <c>Connect</c> returned, its write succeeded into a buffer, and nobody ever read it. The
|
||||||
|
/// launch was told it had rung the doorbell and the window never came forward.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Measured rather than reasoned about, in a container, because the two platforms disagree and this
|
||||||
|
/// machine is only one of them: three rings back to back are answered three times with one instance and
|
||||||
|
/// once with a fresh one per connection. <c>Disconnect</c> ends the conversation and keeps the socket, so
|
||||||
|
/// there is never a moment with nothing listening.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Which makes <c>leaveOpen</c> on the reader load-bearing as well. A <see cref="StreamReader"/> disposes
|
||||||
|
/// the stream it was given, and the stream is now the server that has to survive into the next
|
||||||
|
/// iteration — so the reader that was harmless per-connection would close the doorbell after one ring.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class Doorbell : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly string name;
|
||||||
|
private readonly Action comeForward;
|
||||||
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
|
|
||||||
|
private Task? loop;
|
||||||
|
|
||||||
|
internal Doorbell(string name, Action comeForward)
|
||||||
|
{
|
||||||
|
this.name = name;
|
||||||
|
this.comeForward = comeForward;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Start() => loop = RunAsync(lifetime.Token);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (loop is { } running)
|
||||||
|
{
|
||||||
|
// Awaited rather than abandoned, so the pipe name is free by the time this returns — which
|
||||||
|
// matters for exactly one caller: an update that is about to relaunch this application and
|
||||||
|
// would otherwise race its own successor for the name.
|
||||||
|
await running.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
lifetime.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var server = new NamedPipeServerStream(
|
||||||
|
name,
|
||||||
|
PipeDirection.In,
|
||||||
|
maxNumberOfServerInstances: 1,
|
||||||
|
PipeTransmissionMode.Byte,
|
||||||
|
PipeOptions.Asynchronous);
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// leaveOpen, because this server outlives the conversation. See the remark above.
|
||||||
|
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
||||||
|
{
|
||||||
|
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
comeForward();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HangUp(server);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// A client that connected and hung up mid-sentence. Round again: giving up here would
|
||||||
|
// leave every later launch with nothing to talk to, which is the silent-exit behaviour
|
||||||
|
// this class exists to avoid.
|
||||||
|
HangUp(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
||||||
|
/// an <see cref="IOException"/> above may already have left it in, and the state a second call would
|
||||||
|
/// find. Throwing out of the error path would take the loop with it.
|
||||||
|
/// </remarks>
|
||||||
|
private static void HangUp(NamedPipeServerStream server)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (server.IsConnected)
|
||||||
|
{
|
||||||
|
server.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Already disconnected. Nothing to end.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,6 +204,25 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private UpdateInfo? found;
|
private UpdateInfo? found;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The last thing actually fetched, kept separately from the last thing found.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Because the two can disagree, and when they do it is an apply that pays for it.</b> A check
|
||||||
|
/// overwrites <see cref="found"/>, and the shell now checks while a build is already downloaded and
|
||||||
|
/// waiting — that is how it replaces a pending build when the feed has moved on, and how it takes one
|
||||||
|
/// last look on the way to a restart. Either of those can land a newer answer in <c>found</c> and then
|
||||||
|
/// fail to fetch it, or be given up on halfway; the shell's answer is to restart with the build it
|
||||||
|
/// already has, and <see cref="Matched"/> resolving only against <c>found</c> would have thrown at it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Which turned a slow forge into a refused restart: the offer on screen was real, the package on disk
|
||||||
|
/// was real, and the only thing wrong was that this class had forgotten which one it was.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private UpdateInfo? fetched;
|
||||||
|
|
||||||
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
|
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -321,7 +340,23 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
|
|
||||||
// Velopack reports progress as an Action<int> and the rest of this codebase speaks IProgress<T>,
|
// Velopack reports progress as an Action<int> and the rest of this codebase speaks IProgress<T>,
|
||||||
// so the adaptation happens here rather than leaking the older shape into the view models.
|
// so the adaptation happens here rather than leaking the older shape into the view models.
|
||||||
return manager.DownloadUpdatesAsync(Matched(update), progress.Report, cancellationToken);
|
return DownloadAndRememberAsync(Matched(update), progress, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Recorded after the await and not before, so that a download which threw leaves the previous fetch as
|
||||||
|
/// the one this channel can still apply. See <see cref="fetched"/>.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task DownloadAndRememberAsync(
|
||||||
|
UpdateInfo info,
|
||||||
|
IProgress<int> progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await manager
|
||||||
|
.DownloadUpdatesAsync(info, progress.Report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
fetched = info;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -335,21 +370,39 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
|
/// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
|
||||||
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
|
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
|
||||||
/// <see cref="CheckAsync"/> returned; a mismatch is a bug in this application rather than anything a
|
/// <see cref="CheckAsync"/> returned, or what a download of that then fetched; a mismatch is a bug in
|
||||||
/// user did, which is why it throws rather than resolving to some safe-looking default.
|
/// this application rather than anything a user did, which is why it throws rather than resolving to
|
||||||
|
/// some safe-looking default.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// What was fetched is tried before what was found, because that is the one an apply names and the one a
|
||||||
|
/// later check cannot take away. See <see cref="fetched"/> for how the two come to disagree.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private UpdateInfo Matched(AvailableUpdate update)
|
private UpdateInfo Matched(AvailableUpdate update)
|
||||||
{
|
{
|
||||||
if (found is not { } info
|
if (Names(fetched, update))
|
||||||
|| !string.Equals(info.TargetFullRelease.Version.ToString(), update.Version, StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
return fetched!;
|
||||||
$"No update matching {update.Version} has been found by this channel. "
|
|
||||||
+ "Call CheckAsync and pass back what it returned.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return info;
|
if (Names(found, update))
|
||||||
|
{
|
||||||
|
return found!;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"No update matching {update.Version} has been found or fetched by this channel. "
|
||||||
|
+ "Call CheckAsync and pass back what it returned.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool Names(UpdateInfo? info, AvailableUpdate update) =>
|
||||||
|
info is not null
|
||||||
|
&& string.Equals(
|
||||||
|
info.TargetFullRelease.Version.ToString(),
|
||||||
|
update.Version,
|
||||||
|
StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,27 @@ internal static class Program
|
|||||||
|
|
||||||
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||||
|
|
||||||
|
/*
|
||||||
|
One instance per profile, and nothing above this line is affected by it: the installer's own runs
|
||||||
|
have already exited inside VelopackApp.Run, and the WebView2 environment variable is a property of
|
||||||
|
this process rather than of the application.
|
||||||
|
|
||||||
|
Claimed here rather than after Avalonia is configured, so that a second launch starts nothing — no
|
||||||
|
toolkit, no window, no splash flashing on screen before it thinks better of it. What it does
|
||||||
|
instead is ask the running instance to come forward, which is why this is not simply an exit.
|
||||||
|
|
||||||
|
The handle is held for as long as Main runs, which is as long as the message loop does. See
|
||||||
|
SingleInstance for why it is a file rather than a mutex, and for what the profile scoping buys.
|
||||||
|
*/
|
||||||
|
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||||
|
|
||||||
|
using var claim = SingleInstance.Claim(paths);
|
||||||
|
|
||||||
|
if (claim is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,13 +111,33 @@
|
|||||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
||||||
Margin="24">
|
Margin="24">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Which machine, and at what address. Both selectable, for the reason the session column's own address
|
||||||
|
is — see SessionSidebar.axaml: a TextBlock in Avalonia is a drawing of a string rather than a string,
|
||||||
|
and this pair is the one place the shell says what it is dialling *before* anything has answered.
|
||||||
|
That is exactly when the value is wanted somewhere else: in a ticket, in a message to whoever owns
|
||||||
|
the host, in the ssh command being written in the window beside this one. It is also the moment it is
|
||||||
|
least available anywhere else, because there is no prompt yet and may never be one — a refused
|
||||||
|
connection leaves this card holding the only copy on screen.
|
||||||
|
|
||||||
|
Both rather than only the name. They are two lines of one identifier, and a pair where the first can
|
||||||
|
be taken and the second cannot is a worse answer than either.
|
||||||
|
|
||||||
|
The trimming stays, and the tooltips are what make it safe rather than a trap. A drag reaches only
|
||||||
|
the glyphs that were drawn, so a name or an address wearing an ellipsis has to be taken with
|
||||||
|
select-all — Ctrl+A, then Ctrl+C — which takes the whole value, because the ellipsis is a drawing and
|
||||||
|
not a shorter string. The hover says what the whole value is, so nobody copies half of one without
|
||||||
|
having been told there is a half. Same argument, same shape, as the sidebar's.
|
||||||
|
-->
|
||||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16" FontWeight="Medium"
|
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16"
|
||||||
Foreground="{StaticResource Text}" HorizontalAlignment="Center"
|
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
ToolTip.Tip="{Binding SelectedTab.Label}" />
|
||||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||||
TextTrimming="CharacterEllipsis" />
|
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTip.Tip="{Binding SelectedTab.Address}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
xmlns:import="using:DodoSSH.Client.Import"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.HetznerImportScreen"
|
||||||
|
x:DataType="vm:HetznerImportViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Importing the servers in a Hetzner Cloud project.
|
||||||
|
|
||||||
|
Drawn over the Preferences page inside settings mode, exactly as ImportScreen.axaml is — SettingsNav
|
||||||
|
stays lit on Preferences, the titlebar says "Back to preferences", and neither is reachable from the nav
|
||||||
|
rail. See MainWindowViewModel.IsHetznerImportOpen.
|
||||||
|
|
||||||
|
── WHY THIS ONE IS A WIZARD AND THE ssh_config IMPORTER IS NOT ────────────────────────────────────────
|
||||||
|
That screen reads a file that is already on this machine, so it can scan the moment it opens and show a
|
||||||
|
list. This one cannot: it needs a credential that has to be made in somebody else's console first, and
|
||||||
|
then it makes a request over the internet. Those are two separate moments and the screen is honest about
|
||||||
|
it — step one asks for the token, step two shows what the project holds, step three says what was stored.
|
||||||
|
Both of the first two write nothing.
|
||||||
|
|
||||||
|
── THE TOKEN IS NOT STORED ────────────────────────────────────────────────────────────────────────────
|
||||||
|
It is used for one request and cleared, whether the read succeeded or not. Nothing here writes it to the
|
||||||
|
vault, to settings or to a log, and the next import asks again — the same argument ImportScreen's key
|
||||||
|
tick makes for not remembering a "yes". The note above the box says which permission is needed (Read),
|
||||||
|
that a token is one project rather than one account, and that dedicated servers behind Hetzner's Robot
|
||||||
|
are not covered by any of this.
|
||||||
|
|
||||||
|
── NOTHING IMPORTED HERE CONNECTS ON ITS OWN ──────────────────────────────────────────────────────────
|
||||||
|
Hetzner has never held the private half of an SSH key, so no key can come with these hosts. That is said
|
||||||
|
above the button rather than after it, on the same reasoning ImportScreen.axaml gives for its own version
|
||||||
|
of this sentence: somebody who is not told will conclude the import was broken.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<UserControl.Styles>
|
||||||
|
<!-- Matching ImportScreen's table exactly: this is the same table about a different source. -->
|
||||||
|
<Style Selector="Border.importrow:pointerover">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.new">
|
||||||
|
<Setter Property="Background" Value="{StaticResource LiveWash}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.new > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.exists">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.exists > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.warn">
|
||||||
|
<Setter Property="Background" Value="{StaticResource WarnWash}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.warn > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||||
|
</Style>
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
|
<Grid MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch" RowDefinitions="Auto,Auto,*">
|
||||||
|
|
||||||
|
<!-- ============ HEADER ============ -->
|
||||||
|
<StackPanel Grid.Row="0" Spacing="10">
|
||||||
|
<TextBlock Classes="settingstitle" Text="Import from Hetzner Cloud" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="label" Text="{Binding StepLabel}" FontSize="9.5" LetterSpacing="1"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="mono" Text="{Binding StepTitle}" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="0,12,0,0"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<Panel Grid.Row="2" Margin="0,18,0,0">
|
||||||
|
|
||||||
|
<!-- ============ STEP 1 · THE TOKEN ============ -->
|
||||||
|
<Grid IsVisible="{Binding IsTokenStep}" RowDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
|
<TextBlock Text="Cloud API token" FontSize="13.5" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource Text}" />
|
||||||
|
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
|
||||||
|
Text="{Binding TokenNote}" />
|
||||||
|
<TextBox Text="{Binding Token}" PasswordChar="•" Height="42" FontSize="13"
|
||||||
|
FontFamily="{StaticResource MonoFont}"
|
||||||
|
PlaceholderText="64 characters, from Security → API tokens">
|
||||||
|
<!-- Enter reads the project, so a pasted token does not need the mouse to go anywhere. -->
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||||
|
<Button Classes="ghost" Height="42" Content="CANCEL" Command="{Binding CancelCommand}"
|
||||||
|
ToolTip.Tip="Back to Preferences. Nothing has been sent." />
|
||||||
|
<Button Classes="accent" Height="42" Content="READ THE PROJECT"
|
||||||
|
Command="{Binding ScanCommand}" IsEnabled="{Binding !IsBusy}"
|
||||||
|
ToolTip.Tip="Lists the project's servers. Nothing is stored, and the token is forgotten as soon as the list arrives." />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ============ STEP 2 · WHAT THE PROJECT HOLDS ============ -->
|
||||||
|
<Grid IsVisible="{Binding IsChooseStep}" RowDefinitions="Auto,Auto,*,Auto">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
What every imported host gets: which address, which login user, which port. One set of answers for
|
||||||
|
the whole import rather than one per row, because an estate is reached the same way throughout —
|
||||||
|
a per-server dropdown would be forty answers to one question.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="20,18">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="DIAL" FontSize="9.5" LetterSpacing="1"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
|
<!--
|
||||||
|
Chips rather than radio buttons: this application has no RadioButton style, and Fluent's
|
||||||
|
own beside a row of chips reads as belonging to a different program. Button.chiptoggle is
|
||||||
|
the tag picker's control, which is already "one of these is on" drawn as a chip.
|
||||||
|
-->
|
||||||
|
<Button Classes="chiptoggle" Content="PUBLIC IPv4"
|
||||||
|
Classes.worn="{Binding UsesPublicIPv4}"
|
||||||
|
Command="{Binding UseAddressCommand}"
|
||||||
|
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv4}" />
|
||||||
|
<Button Classes="chiptoggle" Content="PUBLIC IPv6"
|
||||||
|
Classes.worn="{Binding UsesPublicIPv6}"
|
||||||
|
Command="{Binding UseAddressCommand}"
|
||||||
|
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv6}" />
|
||||||
|
<Button Classes="chiptoggle" Content="PRIVATE NETWORK"
|
||||||
|
Classes.worn="{Binding UsesPrivate}"
|
||||||
|
Command="{Binding UseAddressCommand}"
|
||||||
|
CommandParameter="{x:Static import:HetznerAddressKind.Private}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="2" Classes="label" Text="USER" FontSize="9.5" LetterSpacing="1"
|
||||||
|
VerticalAlignment="Center" Margin="14,0,0,0" />
|
||||||
|
<TextBox Grid.Column="3" Text="{Binding Username}" Width="120" Height="34"
|
||||||
|
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
|
||||||
|
ToolTip.Tip="Every Hetzner Linux image puts the project's SSH keys on root and creates no other account." />
|
||||||
|
<TextBox Grid.Column="4" Text="{Binding PortText}" Width="66" Height="34"
|
||||||
|
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
|
||||||
|
ToolTip.Tip="The SSH port every imported host gets." />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ WHERE THEY LAND, AND WHAT THEY AUTHENTICATE WITH. The two answers this import used to
|
||||||
|
decide on somebody's behalf: the active vault, and nothing.
|
||||||
|
|
||||||
|
A project belongs to a team more often than to a person, so the vault is asked rather than
|
||||||
|
assumed — see HetznerImportViewModel.VaultChoices, and the host editor's own picker for why
|
||||||
|
this is its own selection rather than the keychain screen's standing preference. It is hidden
|
||||||
|
at one vault, which is a question with one answer.
|
||||||
|
|
||||||
|
The key is the half Hetzner cannot supply and the keychain usually already has: the public
|
||||||
|
half went to Hetzner when the servers were made, the private half never did. Pointing forty
|
||||||
|
hosts at it here is one answer; without it, it was forty visits to forty host editors. The
|
||||||
|
picker is hidden entirely on a keychain with no keys in it.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="18">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding HasVaultChoice}">
|
||||||
|
<TextBlock Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<ComboBox Width="230" Height="34" ItemsSource="{Binding VaultChoices}"
|
||||||
|
SelectedItem="{Binding SelectedVault}"
|
||||||
|
ToolTip.Tip="Which vault the imported hosts are written into. A shared one is readable by everybody who holds its key.">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding HasKeyChoice}">
|
||||||
|
<TextBlock Classes="label" Text="KEY" FontSize="9.5" LetterSpacing="1"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<ComboBox Width="260" Height="34" ItemsSource="{Binding KeyChoices}"
|
||||||
|
SelectedItem="{Binding SelectedKey}"
|
||||||
|
ToolTip.Tip="A key already in your keychain, which every imported host is pointed at. Nothing is copied — the host stores the key's id, exactly as one bound by hand does.">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:HetznerKeyChoice">
|
||||||
|
<TextBlock Text="{Binding Label}" FontSize="12" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17" TextWrapping="Wrap"
|
||||||
|
Text="{Binding AddressNote}" />
|
||||||
|
|
||||||
|
<TextBlock Classes="hint" FontSize="11" IsVisible="{Binding !IsPortValid}"
|
||||||
|
Foreground="{StaticResource WarnText}"
|
||||||
|
Text="That is not a port. Give a number between 1 and 65535." />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- What the project could not be made to say, at project level. -->
|
||||||
|
<Border Grid.Row="1" Margin="0,14,0,0" Padding="14,10" CornerRadius="10"
|
||||||
|
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||||
|
BorderThickness="1" IsVisible="{Binding HasWarnings}">
|
||||||
|
<ItemsControl ItemsSource="{Binding Warnings}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="x:String">
|
||||||
|
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11.5"
|
||||||
|
TextWrapping="Wrap" Margin="0,2" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ THE TABLE ============ -->
|
||||||
|
<Border Grid.Row="2" Margin="0,14,0,0" CornerRadius="12" Background="{StaticResource Pane}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" ClipToBounds="True"
|
||||||
|
IsVisible="{Binding HasRows}">
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*">
|
||||||
|
<!-- A Button rather than a CheckBox, for the reason ImportScreen.axaml's own header gives. -->
|
||||||
|
<Button Grid.Column="0" Classes="flat" Width="18" Height="18" Padding="0"
|
||||||
|
VerticalAlignment="Center" Command="{Binding ToggleAllCommand}"
|
||||||
|
ToolTip.Tip="Tick or untick every row this address choice can reach">
|
||||||
|
<Panel Width="18" Height="18">
|
||||||
|
<Border CornerRadius="5" Background="{StaticResource Accent}"
|
||||||
|
IsVisible="{Binding AllTicked}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="13"
|
||||||
|
Foreground="White" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<Border CornerRadius="5" BorderBrush="{StaticResource BorderMid}" BorderThickness="1.5"
|
||||||
|
IsVisible="{Binding !AllTicked}" />
|
||||||
|
</Panel>
|
||||||
|
</Button>
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" Text="SERVER" FontSize="9.5" LetterSpacing="1"
|
||||||
|
Margin="14,0,0,0" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
|
||||||
|
<TextBlock Grid.Column="3" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
|
||||||
|
<TextBlock Grid.Column="4" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
||||||
|
<TextBlock Grid.Column="5" Classes="label" Text="WHAT THIS MEANS" FontSize="9.5"
|
||||||
|
LetterSpacing="1" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1">
|
||||||
|
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:HetznerRowViewModel">
|
||||||
|
<Border Classes="importrow" CornerRadius="8">
|
||||||
|
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*" Margin="12" MinHeight="20">
|
||||||
|
<!--
|
||||||
|
Disabled where the address choice cannot reach this server, which is the private
|
||||||
|
case: a server on no private network is not importable and must not be silently
|
||||||
|
given a public address instead.
|
||||||
|
-->
|
||||||
|
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}"
|
||||||
|
IsEnabled="{Binding CanImport}" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
|
||||||
|
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="14,0,10,0"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="11.5"
|
||||||
|
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="11.5"
|
||||||
|
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Kind}" FontSize="11.5"
|
||||||
|
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
<Border Grid.Column="5" Classes="meaningchip" CornerRadius="5" Padding="8,3"
|
||||||
|
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||||
|
Classes.new="{Binding IsMeaningNew}"
|
||||||
|
Classes.exists="{Binding IsMeaningExisting}"
|
||||||
|
Classes.warn="{Binding IsMeaningWarned}" ToolTip.Tip="{Binding Meaning}">
|
||||||
|
<TextBlock Text="{Binding Meaning}" FontSize="10" FontWeight="SemiBold"
|
||||||
|
MaxWidth="230" TextTrimming="CharacterEllipsis" />
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ FOOTER ============ -->
|
||||||
|
<StackPanel Grid.Row="3" Margin="0,16,0,0" Spacing="14">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Said before the button. Whether a credential comes with the host is the difference between a
|
||||||
|
bookmark that connects and one that asks for a password, and nothing about a Hetzner project can
|
||||||
|
supply one.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||||
|
Text="{Binding AuthenticationNote}" />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="hint" FontSize="12" VerticalAlignment="Center"
|
||||||
|
Text="{Binding SelectionSummary}" />
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
|
||||||
|
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN"
|
||||||
|
Command="{Binding RestartCommand}"
|
||||||
|
ToolTip.Tip="Back to the token box, for another project. This list goes with it — the token it came from was not kept." />
|
||||||
|
<Button Classes="ghost" Height="44" Content="CANCEL" Command="{Binding CancelCommand}"
|
||||||
|
ToolTip.Tip="Back to Preferences. Nothing is stored." />
|
||||||
|
<Button Classes="accent" Height="44" Content="{Binding ImportLabel}"
|
||||||
|
Command="{Binding ImportCommand}" IsEnabled="{Binding CanImport}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ============ STEP 3 · WHAT WAS STORED ============ -->
|
||||||
|
<Grid IsVisible="{Binding IsDoneStep}" RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
|
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="What happens next" FontSize="13.5" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource Text}" />
|
||||||
|
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
|
||||||
|
Text="{Binding AuthenticationNote}" />
|
||||||
|
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
|
||||||
|
Text="The list this came from is one press away. Going back to it and importing the rest costs nothing — what was just stored is marked as already here. It is the list this scan produced, though, not a fresh one: a server made in the Hetzner console since then is not in it." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right"
|
||||||
|
Margin="0,16,0,0">
|
||||||
|
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN" Command="{Binding RestartCommand}"
|
||||||
|
ToolTip.Tip="Back to the token box, for another project." />
|
||||||
|
<Button Classes="ghost" Height="44" Content="BACK TO THE LIST"
|
||||||
|
Command="{Binding ReviewCommand}"
|
||||||
|
ToolTip.Tip="The same servers, with what you just stored marked as already here." />
|
||||||
|
<Button Classes="accent" Height="44" Content="DONE" Command="{Binding CancelCommand}"
|
||||||
|
ToolTip.Tip="Back to Preferences." />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Importing hosts from a Hetzner Cloud project.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A task rather than a destination, reached from Preferences and not from the nav rail, and drawn inside
|
||||||
|
/// settings mode over the Preferences page — the same shape <see cref="ImportScreen"/> has, for the same
|
||||||
|
/// reasons, including having no keyboard target of its own to hand back.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// No code behind it, unlike <see cref="ImportScreen"/>, and the difference is worth a sentence: that screen
|
||||||
|
/// listens for bubbled tick events to keep its footer count honest, because its rows have no way to reach the
|
||||||
|
/// list they are in. This one's view model subscribes to each row's own change notification instead — see
|
||||||
|
/// <c>HetznerImportViewModel.OnRowChanged</c> — which puts the same wiring where the count actually lives
|
||||||
|
/// rather than in a routed-event handler on a control.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class HetznerImportScreen : UserControl
|
||||||
|
{
|
||||||
|
public HetznerImportScreen() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -457,11 +457,12 @@
|
|||||||
something that refuses it, leaving the window with nothing focused and the keystrokes going
|
something that refuses it, leaving the window with nothing focused and the keystrokes going
|
||||||
nowhere.
|
nowhere.
|
||||||
|
|
||||||
◆ EVERY GROUP, ONE AFTER ANOTHER, "No group" FIRST. Binds HostSections rather than VisibleHosts:
|
◆ EVERY VAULT, AND INSIDE EACH ONE EVERY GROUP, "No group" FIRST. Binds HostSections rather
|
||||||
the old grid held one level of a tree at a time, and this holds every group's cards at once, the
|
than VisibleHosts: the old grid held one level of a tree at a time, and this holds every group's
|
||||||
way SidebarRows always has for the phone. A keychain nobody has filed anything in draws one
|
cards at once, the way SidebarRows always has for the phone. A keychain nobody has filed
|
||||||
section with no heading at all — see HostSectionViewModel.HasHeader — which is what makes the
|
anything in draws one section with no heading at all — see HostSectionViewModel.HasHeader —
|
||||||
feature invisible until it is used.
|
which is what makes the feature invisible until it is used. A session holding one vault draws no
|
||||||
|
vault headings either, for the same reason and by the same rule.
|
||||||
-->
|
-->
|
||||||
<ItemsControl x:Name="Board" Focusable="True" ItemsSource="{Binding HostSections}">
|
<ItemsControl x:Name="Board" Focusable="True" ItemsSource="{Binding HostSections}">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
@@ -472,6 +473,27 @@
|
|||||||
<DataTemplate x:DataType="vm:HostSectionViewModel">
|
<DataTemplate x:DataType="vm:HostSectionViewModel">
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============ THE VAULT HEADING ============
|
||||||
|
◆ THE BOARD'S OUTER LEVEL. A vault holds groups and a group holds hosts, and until this
|
||||||
|
row existed the board drew only the inner two — every group of every vault in one run,
|
||||||
|
told apart by a small grey vault name beside each heading. Two vaults each holding a
|
||||||
|
"production" were two headings with the same word on them.
|
||||||
|
|
||||||
|
Drawn on the first section of each vault and nowhere else — see
|
||||||
|
HostSectionViewModel.VaultHeading — and on no section at all where the board has one
|
||||||
|
vault, which is what keeps a single-vault keychain looking exactly as it did. The rule
|
||||||
|
to its right is the whole of the emphasis: this level is a divider with a name on it,
|
||||||
|
not a heading competing with the group headings under it.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding StartsAVault}" Margin="0,2,0,2">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding VaultHeading}" FontSize="11.5"
|
||||||
|
FontWeight="Bold" Foreground="{StaticResource Text}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<Border Grid.Column="1" Height="1" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||||
|
Background="{StaticResource BorderSubtle}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
============ THE SECTION HEADING ============
|
============ THE SECTION HEADING ============
|
||||||
"{label} · {count}", the vault name beside it when more than one vault is in play, a
|
"{label} · {count}", the vault name beside it when more than one vault is in play, a
|
||||||
@@ -569,6 +591,18 @@
|
|||||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).OpenHostPaneCommand}" />
|
Command="{Binding #Board.((vm:VaultViewModel)DataContext).OpenHostPaneCommand}" />
|
||||||
<MenuItem Header="Edit…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
<MenuItem Header="Edit…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).EditSelectedHostCommand}" />
|
Command="{Binding #Board.((vm:VaultViewModel)DataContext).EditSelectedHostCommand}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE SAME ENTRY THE DRAWER'S ⋯ MENU HAS, on the board where the host is. Moving
|
||||||
|
one machine used to mean opening its pane first and then finding the menu inside
|
||||||
|
it, while moving six was a right click away — the one-host case was the harder of
|
||||||
|
the two, which is backwards. The command raises the drawer itself (see
|
||||||
|
VaultViewModel.MoveHost); the picker it opens lives in that footer and nowhere
|
||||||
|
else, and an entry that armed a panel nobody could see would be a menu item that
|
||||||
|
did nothing.
|
||||||
|
-->
|
||||||
|
<MenuItem Header="Move to another vault…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||||
|
Command="{Binding #Board.((vm:VaultViewModel)DataContext).MoveHostCommand}" />
|
||||||
<Separator IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" />
|
<Separator IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" />
|
||||||
<MenuItem Header="Delete…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
<MenuItem Header="Delete…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).DeleteHostCommand}" />
|
Command="{Binding #Board.((vm:VaultViewModel)DataContext).DeleteHostCommand}" />
|
||||||
@@ -600,16 +634,26 @@
|
|||||||
The full address is on the tooltip and nowhere else on the card — the design's own
|
The full address is on the tooltip and nowhere else on the card — the design's own
|
||||||
rule, kept: a card read while scanning a section is read for its name and whether
|
rule, kept: a card read while scanning a section is read for its name and whether
|
||||||
it is live, and an address is what somebody reads once they have found the machine,
|
it is live, and an address is what somebody reads once they have found the machine,
|
||||||
which the tooltip and the drawer both still say.
|
which the tooltip and the drawer both still say. The name is on the tooltip too,
|
||||||
|
and that is not duplication of the line six pixels above it: 214 pixels of card
|
||||||
|
trims it, and the tooltip is where the untrimmed one lives. See
|
||||||
|
HostRowViewModel.Tooltip.
|
||||||
|
|
||||||
MinHeight rather than a fixed Height, unlike the mock's own 120: this codebase's
|
MinHeight rather than a fixed Height, unlike the mock's own 120: this codebase's
|
||||||
Border.tile is deliberately a fixed width and a free height (see App.axaml), so a
|
Border.tile is deliberately a fixed width and a free height (see App.axaml), so a
|
||||||
card wearing four tags grows a second line rather than clipping the fourth one — a
|
card wearing four tags grows a second line rather than clipping the fourth one — a
|
||||||
silent truncation is exactly the kind of thing the honesty rule in
|
silent truncation is exactly the kind of thing the honesty rule in
|
||||||
HANDOFF-hosts-v5.md exists to catch, and it costs nothing here to avoid.
|
HANDOFF-hosts-v5.md exists to catch, and it costs nothing here to avoid.
|
||||||
|
|
||||||
|
◆ 104 RATHER THAN THE MOCK'S 120, and the floor is the only thing that moved. What
|
||||||
|
a card with no tags actually needs is about 87 — 20 of padding, 43 of name and
|
||||||
|
address, 24 for the row along the bottom — so 120 was thirty pixels of air below
|
||||||
|
every card on a board that is scrolled. It is still a floor with room in it: the
|
||||||
|
number is deliberately above what the content measures, so the common card is a
|
||||||
|
uniform height rather than each one shrink-wrapping its own last line.
|
||||||
-->
|
-->
|
||||||
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="120"
|
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="104"
|
||||||
ToolTip.Tip="{Binding Address}">
|
ToolTip.Tip="{Binding Tooltip}">
|
||||||
<Panel>
|
<Panel>
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,9 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
|
|
||||||
if (control)
|
if (control)
|
||||||
{
|
{
|
||||||
vault.ToggleHostChoiceCommand.Execute(row);
|
// Extend rather than toggle: the set this starts has the lit card in it, which is the half a
|
||||||
|
// plain toggle left out. See VaultViewModel.ExtendHostChoice.
|
||||||
|
vault.ExtendHostChoiceCommand.Execute(row);
|
||||||
anchor = row;
|
anchor = row;
|
||||||
|
|
||||||
Board.Focus();
|
Board.Focus();
|
||||||
|
|||||||
@@ -252,13 +252,23 @@
|
|||||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
|
◆ ONE ROW, WHERE v5c PUT THREE.
|
||||||
see MainWindowViewModel.EnterSettings and SettingsView.axaml. Settings opens on General, the
|
|
||||||
mode's own default landing page; Vaults and Preferences open directly on the page they name,
|
Settings, Vaults and Preferences all opened the same full-window settings mode — see
|
||||||
which is also what anything that used to navigate to ShellScreen.Vaults or
|
MainWindowViewModel.EnterSettings and SettingsView.axaml — differing only in which of its
|
||||||
ShellScreen.Preferences now does — see ShowScreen. Three handlers rather than the two rows
|
seven pages they landed on. Two of the three were therefore deep links into a place the third
|
||||||
sharing one before this wave: the mock's Settings area was a family of screens that did not
|
one opens, and SettingsNav is already on screen the whole time listing every page including
|
||||||
exist yet, and now that it does, "Settings" and "Preferences" are no longer the same click.
|
those two, so the shortcut saved one click and cost a menu that read as though it offered
|
||||||
|
three destinations.
|
||||||
|
|
||||||
|
It was three rows because settings mode had only just been built out of what used to be two
|
||||||
|
bare ShellScreens: "Settings" and "Preferences" were the same click before that wave, and
|
||||||
|
splitting them was the fix for a real thing. Folding the other two back in is not a return to
|
||||||
|
that state — Vaults and Preferences are pages now, reached from the rail inside settings, and
|
||||||
|
what this menu is for is getting there at all.
|
||||||
|
|
||||||
|
What still lands on a page rather than on General is Logout below, and it has to: its
|
||||||
|
confirmation card is drawn inline on the Account page and nowhere else.
|
||||||
-->
|
-->
|
||||||
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
|
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
@@ -268,29 +278,6 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!--
|
|
||||||
◆ THE SAME TREATMENT AS SETTINGS ABOVE AND LOGOUT BELOW, which these two did not have: their
|
|
||||||
label was TextGhost where the other two rows' was Text, so a menu of five equally live
|
|
||||||
destinations drew two of them in the colour this window uses for something switched off. The
|
|
||||||
icons stay one step quieter than the words — the idiom the nav rail's own rows already follow
|
|
||||||
— but "quieter than the word beside it" and "dimmed" are not the same statement.
|
|
||||||
-->
|
|
||||||
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
|
||||||
Foreground="{StaticResource TextGhost}" />
|
|
||||||
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource Text}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
|
||||||
Foreground="{StaticResource TextGhost}" />
|
|
||||||
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource Text}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -59,28 +59,6 @@ internal sealed partial class NavRail : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opens settings mode on its Preferences page.</summary>
|
|
||||||
private void OnPopoverPreferencesPressed(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
ClosePopover();
|
|
||||||
|
|
||||||
if (DataContext is MainWindowViewModel shell)
|
|
||||||
{
|
|
||||||
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Opens settings mode on its Vaults page.</summary>
|
|
||||||
private void OnPopoverVaultsPressed(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
ClosePopover();
|
|
||||||
|
|
||||||
if (DataContext is MainWindowViewModel shell)
|
|
||||||
{
|
|
||||||
shell.EnterSettingsCommand.Execute(SettingsPage.Vaults);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts a sign-out, through the Account settings page so the confirmation card has somewhere to be
|
/// Starts a sign-out, through the Account settings page so the confirmation card has somewhere to be
|
||||||
/// seen — see <see cref="MainWindowViewModel.SignOutFromPopover"/>.
|
/// seen — see <see cref="MainWindowViewModel.SignOutFromPopover"/>.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||||
<TextBlock Classes="settingsrowtitle" Text="Check on its own" />
|
<TextBlock Classes="settingsrowtitle" Text="Check on its own" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
|
Text="Every six hours while DodoSSH is running, starting at launch. It keeps checking while the keychain is locked — where builds come from has nothing to do with your vault — and while an update is already waiting, so what is offered is the newest build rather than the first one this run happened to see." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<CheckBox Grid.Column="1" VerticalAlignment="Top"
|
<CheckBox Grid.Column="1" VerticalAlignment="Top"
|
||||||
IsChecked="{Binding Updates.IsAutomatic}"
|
IsChecked="{Binding Updates.IsAutomatic}"
|
||||||
|
|||||||
@@ -15,8 +15,10 @@
|
|||||||
|
|
||||||
TERMINAL keeps its one real setting, text size, and drops the design's other five — font, cursor,
|
TERMINAL keeps its one real setting, text size, and drops the design's other five — font, cursor,
|
||||||
scrollback, copy on select, terminal bell — which the renderer hard-codes. KEYCHAIN keeps Lock now,
|
scrollback, copy on select, terminal bell — which the renderer hard-codes. KEYCHAIN keeps Lock now,
|
||||||
Sync now/Sign in, and the importer row exactly as the old screen had them; the design's own SECURITY
|
Sync now/Sign in, and the importer rows; the design's own SECURITY card (clipboard-clear delay,
|
||||||
card (clipboard-clear delay, confirm-run-on-insert) has nothing behind either row and is not drawn here.
|
confirm-run-on-insert) has nothing behind either row and is not drawn here.
|
||||||
|
|
||||||
|
Two importer rows now rather than the one the old screen had: ~/.ssh/config, and a Hetzner Cloud project.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
@@ -95,7 +97,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Classes="settingsrow last">
|
<Border Classes="settingsrow">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||||
<TextBlock Classes="settingsrowtitle" Text="Import SSH config" />
|
<TextBlock Classes="settingsrowtitle" Text="Import SSH config" />
|
||||||
@@ -108,6 +110,24 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The second importer, beside the first rather than folded into it: they read different things
|
||||||
|
through different code, and one row that asked "from where?" would put a question in front of
|
||||||
|
the far more common answer. See HetznerImportScreen.axaml.
|
||||||
|
-->
|
||||||
|
<Border Classes="settingsrow last">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Import from Hetzner Cloud" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Lists the servers in one Hetzner Cloud project and imports the ones you tick. Needs a read-only API token, which is used for a single request and never stored; a token belongs to one project, so an account with several runs this once each. No key can come with them — Hetzner has never held the private half of one — so imported hosts ask for a password until you bind them to a key in your keychain. Dedicated servers behind Hetzner's Robot are not covered." />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN WIZARD"
|
||||||
|
Command="{Binding ShowScreenCommand}"
|
||||||
|
CommandParameter="{x:Static vm:ShellScreen.HetznerImport}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,12 @@
|
|||||||
bars needs its own window behaviour a shared base would have to be pulled apart first.
|
bars needs its own window behaviour a shared base would have to be pulled apart first.
|
||||||
|
|
||||||
v5c-3: two back buttons rather than one whose text and command a converter swaps, toggled by
|
v5c-3: two back buttons rather than one whose text and command a converter swaps, toggled by
|
||||||
MainWindowViewModel.IsImportOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in
|
MainWindowViewModel.IsImporterOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in
|
||||||
place of "Back to application" while the importer is up, and CloseImportCommand closes it without leaving
|
place of "Back to application" while an importer is up, and CloseImportCommand closes it without leaving
|
||||||
settings mode, unlike LeaveSettingsCommand.
|
settings mode, unlike LeaveSettingsCommand.
|
||||||
|
|
||||||
|
IsImporterOpen rather than IsImportOpen because there are two importers now — the ssh_config one and the
|
||||||
|
Hetzner wizard — and this bar behaves identically for both: same label, same destination, same command.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||||
@@ -28,7 +31,7 @@
|
|||||||
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
||||||
|
|
||||||
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
||||||
IsVisible="{Binding !IsImportOpen}"
|
IsVisible="{Binding !IsImporterOpen}"
|
||||||
Command="{Binding LeaveSettingsCommand}"
|
Command="{Binding LeaveSettingsCommand}"
|
||||||
ToolTip.Tip="Back to the screen you were on before opening Settings">
|
ToolTip.Tip="Back to the screen you were on before opening Settings">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||||
@@ -40,7 +43,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
||||||
IsVisible="{Binding IsImportOpen}"
|
IsVisible="{Binding IsImporterOpen}"
|
||||||
Command="{Binding CloseImportCommand}"
|
Command="{Binding CloseImportCommand}"
|
||||||
ToolTip.Tip="Back to preferences, without leaving Settings">
|
ToolTip.Tip="Back to preferences, without leaving Settings">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
because ActiveSettingsPage never actually leaves SettingsPage.Preferences; only IsImportOpen and the pair
|
because ActiveSettingsPage never actually leaves SettingsPage.Preferences; only IsImportOpen and the pair
|
||||||
of IsVisible bindings below move. Its data context is ImportScreen rather than this control's own, the
|
of IsVisible bindings below move. Its data context is ImportScreen rather than this control's own, the
|
||||||
same split MainWindow.axaml drew before the importer moved in here.
|
same split MainWindow.axaml drew before the importer moved in here.
|
||||||
|
|
||||||
|
HetznerImportScreen joined it on identical terms. Two flags rather than one "which importer" value,
|
||||||
|
because they are two independent facts about the content column and every path that opens either closes
|
||||||
|
the other; what the Preferences page below asks is IsSettingsPreferencesContentShowing, which is written
|
||||||
|
against both.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*" Background="{StaticResource Canvas}">
|
<Grid RowDefinitions="Auto,*" Background="{StaticResource Canvas}">
|
||||||
@@ -59,6 +64,11 @@
|
|||||||
<views:ImportScreen DataContext="{Binding ImportScreen}" />
|
<views:ImportScreen DataContext="{Binding ImportScreen}" />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- The Hetzner wizard, on exactly the same terms. See MainWindowViewModel.IsHetznerImportOpen. -->
|
||||||
|
<Panel IsVisible="{Binding IsHetznerImportOpen}">
|
||||||
|
<views:HetznerImportScreen DataContext="{Binding HetznerImportScreen}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SplashCard">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
What is on screen while the application composes itself.
|
||||||
|
|
||||||
|
── WHY THIS IS ITS OWN CONTROL ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
The same reason UpdateBanner is: a Window cannot be laid out by the headless suite when it contains a
|
||||||
|
WebView, and while this one does not, a UserControl is what LayoutHarness can host and measure. See
|
||||||
|
SplashCardTests. It also keeps the window itself down to a frame and a size.
|
||||||
|
|
||||||
|
── AND WHY THERE IS NO VIEW MODEL ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
There is nothing to observe. Composition is one synchronous block — see DodoSshApp.ComposeBehind — so
|
||||||
|
this control has exactly two states, both of them set by the one caller: starting, and could not. Two
|
||||||
|
methods on the code-behind is the whole of it, and a view model would be an observable object with a
|
||||||
|
lifetime and a test of its own standing in for two assignments.
|
||||||
|
-->
|
||||||
|
<Border Background="{StaticResource Chrome}"
|
||||||
|
BorderBrush="{StaticResource Border}"
|
||||||
|
BorderThickness="1">
|
||||||
|
|
||||||
|
<StackPanel Margin="28" Spacing="12" VerticalAlignment="Center">
|
||||||
|
|
||||||
|
<TextBlock Classes="heading" Text="DodoSSH" />
|
||||||
|
|
||||||
|
<TextBlock x:Name="Line" Classes="hint" Text="Starting…" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── AND NO PROGRESS BAR, WHICH IS A REMOVAL RATHER THAN AN OMISSION ─────────────────────────────────
|
||||||
|
|
||||||
|
There was an indeterminate one here, and on screen it did not move. The reason is the same fact this
|
||||||
|
whole window exists for: composition is a synchronous block, and DodoSshApp posts it to the very
|
||||||
|
dispatcher that would have to animate the bar. Background priority buys the splash its first paint
|
||||||
|
and nothing after it — once composition starts, the UI thread is inside it until the main window is
|
||||||
|
ready, and an indeterminate bar's animation is driven by that thread's clock. So it painted one
|
||||||
|
frame and froze.
|
||||||
|
|
||||||
|
A frozen indeterminate bar does not read as "indeterminate". It reads as a determinate bar stuck at
|
||||||
|
zero, which is the one thing worse than no bar at all: it says the work has stalled at the exact
|
||||||
|
moment the application is busiest. Nothing here can honestly be measured either — a SQLite open, a
|
||||||
|
loopback bind, a TPM probe and an install-layout read report no progress between them.
|
||||||
|
|
||||||
|
So the line above is the whole of it. "Starting…" is true for as long as it is on screen, it needs
|
||||||
|
no clock to stay true, and the window closing is what says it finished.
|
||||||
|
-->
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>What is on screen while the application composes itself.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The two methods below are the whole interface, and both are called by
|
||||||
|
/// <c>DodoSshApp.ComposeBehind</c> and by nothing else. See the control's own markup for why there is no
|
||||||
|
/// view model behind it.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class SplashCard : UserControl
|
||||||
|
{
|
||||||
|
public SplashCard() => InitializeComponent();
|
||||||
|
|
||||||
|
/// <summary>Says what is happening, while it still is.</summary>
|
||||||
|
internal void Say(string what) => Line.Text = what;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Says why nothing is going to happen, and stops pretending otherwise.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The line is the whole message now that the progress bar has gone — see the markup for why it went.
|
||||||
|
/// It used to have to stop the bar as well: a composition that threw leaves this window as the only one
|
||||||
|
/// there is, with no main window to hand over to, so anything still claiming to be working would be
|
||||||
|
/// doing it over a process that has stopped. One assignment says it instead, and there is nothing left
|
||||||
|
/// on this card that could contradict it.
|
||||||
|
/// </remarks>
|
||||||
|
internal void CouldNotStart(string why) => Line.Text = why;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SplashWindow"
|
||||||
|
Title="DodoSSH"
|
||||||
|
Icon="/Assets/dodossh.ico"
|
||||||
|
Width="420"
|
||||||
|
MinHeight="160"
|
||||||
|
SizeToContent="Height"
|
||||||
|
CanResize="False"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Background="{StaticResource Chrome}"
|
||||||
|
WindowDecorations="None">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── WHY THERE IS A SPLASH AT ALL ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
The main window cannot be shown until it has a view model, and the view model cannot exist until
|
||||||
|
composition has opened the SQLite cache, bound the terminal's loopback socket, asked the platform about
|
||||||
|
a TPM and read the install layout off disk. Until this window existed, all of that happened with nothing
|
||||||
|
whatever on screen: the icon had been double-clicked and the desktop looked unchanged.
|
||||||
|
|
||||||
|
The main window's own IsStarting card already narrates what happens *after* it appears — see
|
||||||
|
MainWindow.axaml — and this is the part before that, which no arrangement of the main window can cover.
|
||||||
|
|
||||||
|
── NOT TOPMOST, AND NO TASKBAR SUPPRESSION ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Both are the usual splash-screen habits and both are wrong here. Topmost means a start that stalls sits
|
||||||
|
over whatever the user gave up and went back to; hiding the taskbar entry means the one place they would
|
||||||
|
look to find out whether anything is happening says nothing. It is a window, briefly, and behaves like
|
||||||
|
one.
|
||||||
|
|
||||||
|
WindowDecorations="None" rather than the BorderOnly the main window asks for: this cannot be resized, has
|
||||||
|
no titlebar of its own to drag by, and lives for about a second. (The main window still says
|
||||||
|
SystemDecorations, which Avalonia 12 has deprecated in favour of this property; that one is not this
|
||||||
|
change's to move.) What replaces the frame is the card's
|
||||||
|
own one-pixel border. Escape closes it only once there is nothing left to wait for — see the code-behind.
|
||||||
|
|
||||||
|
── SizeToContent, WHICH IS ABOUT THE FAILURE AND NOT ABOUT THE SPLASH ─────────────────────────────────
|
||||||
|
|
||||||
|
A fixed height is right for the one line this normally shows and wrong for the one thing that can replace
|
||||||
|
it: CouldNotStart carries an exception's own message, whose length nothing here decides. Inside a fixed
|
||||||
|
height a long one would wrap and be clipped, at exactly the moment the text is the only thing this window
|
||||||
|
is for. So the height comes from the content, and MinHeight keeps the ordinary one line from looking
|
||||||
|
cramped in a box sized for it alone.
|
||||||
|
-->
|
||||||
|
<views:SplashCard x:Name="Card" />
|
||||||
|
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>The window that is up while the application composes itself.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A frame around <see cref="SplashCard"/> and nothing else. Its whole lifetime is one call to
|
||||||
|
/// <c>DodoSshApp.ComposeBehind</c>: shown by the desktop lifetime as the main window, replaced by the real
|
||||||
|
/// one, closed. The exception is a composition that fails, which is the only case this window outlives.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class SplashWindow : Window
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether Escape may close this.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// False while composition is running, and that is not caution for its own sake: this window has no
|
||||||
|
/// close button, so Escape is the only key it could offer — and closing it mid-composition would leave a
|
||||||
|
/// process that has opened the cache and bound a socket with no window and no way to reach either. Once
|
||||||
|
/// there is nothing left to wait for, it is the only way out and has to work.
|
||||||
|
/// </remarks>
|
||||||
|
private bool escapable;
|
||||||
|
|
||||||
|
public SplashWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
KeyDown += (_, e) =>
|
||||||
|
{
|
||||||
|
if (escapable && e.Key == Key.Escape)
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Says what is happening, while it still is.</summary>
|
||||||
|
internal void Say(string what) => Card.Say(what);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Says why nothing is going to happen, and becomes the last window there is.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The way out is named in the message rather than drawn as a button. A failure here is a broken
|
||||||
|
/// installation — a cache that cannot be opened, a port that cannot be bound — so what this window is
|
||||||
|
/// for at that point is carrying one sentence to whoever has to act on it, and a control that has to be
|
||||||
|
/// laid out and measured to say "close me" earns less than the words do.
|
||||||
|
/// </remarks>
|
||||||
|
internal void CouldNotStart(string why)
|
||||||
|
{
|
||||||
|
escapable = true;
|
||||||
|
|
||||||
|
Card.CouldNotStart($"DodoSSH could not start: {why} Press Escape to close.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Reading an OpenSSH client configuration and turning it into hosts this application can store.
|
Turning somewhere an estate is already described into hosts this application can store. Two sources:
|
||||||
|
an OpenSSH client configuration on this machine, and a Hetzner Cloud project over its API.
|
||||||
|
|
||||||
Its own project rather than a folder in DodoSSH.Client.Domain, which holds decrypted item shapes and
|
Its own project rather than a folder in DodoSSH.Client.Domain, which holds decrypted item shapes and
|
||||||
their codecs and has no package references at all. A parser, a resolver and a file-system walk are a
|
their codecs and has no package references at all. A parser, a resolver and a file-system walk are a
|
||||||
different concern with different dependencies, and keeping them apart is what lets the whole of the
|
different concern with different dependencies, and keeping them apart is what lets the whole of the
|
||||||
parsing be tested with no Avalonia, no SQLite and no disk.
|
parsing be tested with no Avalonia, no SQLite and no disk.
|
||||||
|
|
||||||
|
The Hetzner reader is here rather than in a project of its own because it is the same concern arriving
|
||||||
|
from somewhere else: a candidate list, warnings about what could not be represented, and a HostSecret at
|
||||||
|
the end of it. It brings no package reference with it — HttpClient and System.Text.Json are the shared
|
||||||
|
framework — and it stays testable on the same terms, against a stubbed HttpMessageHandler rather than
|
||||||
|
against strings.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hetzner Cloud answered, and the answer was not a list of servers.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One exception type with a sentence already fit to show somebody, rather than a status code every caller
|
||||||
|
/// would have to translate for itself. The status is kept beside it because "401" and "503" are different
|
||||||
|
/// problems with different next steps — one is the token, the other is nothing the user can do — and the
|
||||||
|
/// screen distinguishes them by <see cref="IsTokenProblem"/> rather than by matching on prose.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HetznerCloudException : Exception
|
||||||
|
{
|
||||||
|
/// <summary>Creates a failure that has no underlying exception — a refusal Hetzner spelled out.</summary>
|
||||||
|
public HetznerCloudException(HttpStatusCode? statusCode, string? code, string message)
|
||||||
|
: this(statusCode, code, message, innerException: null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a failure wrapping the one the transport or the parser threw.</summary>
|
||||||
|
public HetznerCloudException(
|
||||||
|
HttpStatusCode? statusCode,
|
||||||
|
string? code,
|
||||||
|
string message,
|
||||||
|
Exception? innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
StatusCode = statusCode;
|
||||||
|
Code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What Hetzner returned, or null where the request never got an answer at all.</summary>
|
||||||
|
public HttpStatusCode? StatusCode { get; }
|
||||||
|
|
||||||
|
/// <summary>Hetzner's own machine-readable error code, where the body carried one.</summary>
|
||||||
|
public string? Code { get; }
|
||||||
|
|
||||||
|
/// <summary>Whether the token is the thing to go and fix.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The wizard's first step is where a token gets typed, so a failure that is about the token belongs
|
||||||
|
/// back on that step and a failure that is about Hetzner's weather does not — sending somebody back to
|
||||||
|
/// re-type a token that was fine, because a datacentre had a bad minute, teaches them the token is
|
||||||
|
/// wrong.
|
||||||
|
/// </remarks>
|
||||||
|
public bool IsTokenProblem =>
|
||||||
|
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the servers in one Hetzner Cloud project.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One token is one project.</b> Hetzner issues Cloud API tokens per project and the API offers no way
|
||||||
|
/// to ask which project a token belongs to, or to list the others — so this reads exactly the project whose
|
||||||
|
/// token it was handed, and the screen says as much rather than implying it found everything the account
|
||||||
|
/// owns. Somebody with four projects runs this four times.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Read is all it does.</b> Only <c>GET /v1/servers</c> is ever called, which is why the screen asks for
|
||||||
|
/// a read-only token: a Read & Write token would work identically here and would be a credential capable
|
||||||
|
/// of deleting the estate, pasted into a text box for no gain.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The token is never stored.</b> It lives in the view model's field for as long as the wizard is open
|
||||||
|
/// and goes no further — not into the vault, not into settings, not into a log. The next import asks again,
|
||||||
|
/// which is the same reasoning <c>ImportViewModel.ImportsKeys</c> gives for not remembering its tick.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Dedicated servers are not here.</b> Hetzner's Robot — the dedicated-server and Storage Box side of the
|
||||||
|
/// business — is a different API behind different credentials, and nothing in this file touches it. An
|
||||||
|
/// account with both gets its cloud servers from this wizard and adds the rest by hand.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HetznerCloudClient
|
||||||
|
{
|
||||||
|
/// <summary>The Cloud API, and the one address in this file.</summary>
|
||||||
|
public static readonly Uri CloudApi = new("https://api.hetzner.cloud/v1/", UriKind.Absolute);
|
||||||
|
|
||||||
|
/// <summary>What Hetzner will return per page, which is also its maximum.</summary>
|
||||||
|
private const int PerPage = 50;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many pages this will follow before giving up.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A backstop against a paginator that never says "no more pages" rather than a limit anybody's estate
|
||||||
|
/// will meet: at fifty a page this is five thousand servers, and the warning it emits on the way out
|
||||||
|
/// says the list is short rather than letting it look complete.
|
||||||
|
/// </remarks>
|
||||||
|
private const int MaxPages = 100;
|
||||||
|
|
||||||
|
private readonly HttpClient http;
|
||||||
|
|
||||||
|
/// <summary>Creates a reader over its own transport, which is how the application builds one.</summary>
|
||||||
|
public HetznerCloudClient()
|
||||||
|
: this(new HttpClient { BaseAddress = CloudApi })
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a reader with its transport supplied, which is how the tests reach it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The base address is set here when the caller left it unset, so a stubbed <c>HttpMessageHandler</c>
|
||||||
|
/// can be handed over on its own without every test repeating the one URL this class knows.
|
||||||
|
/// </remarks>
|
||||||
|
public HetznerCloudClient(HttpClient http)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(http);
|
||||||
|
|
||||||
|
http.BaseAddress ??= CloudApi;
|
||||||
|
|
||||||
|
this.http = http;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads every server the token's project holds.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Pages are followed rather than assumed away: twenty-six servers in a project is enough to meet the
|
||||||
|
/// second page, and an importer that quietly showed the first fifty would offer a list that looks
|
||||||
|
/// complete and is not.
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="HetznerCloudException">
|
||||||
|
/// The token was refused, the project could not be read, or Hetzner could not be reached. Every failure
|
||||||
|
/// arrives as this, with a sentence already fit to put on the screen.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<HetznerImport> ListServersAsync(string token, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var bearer = Sanitise(token);
|
||||||
|
|
||||||
|
var servers = new List<HetznerServer>();
|
||||||
|
var warnings = new List<string>();
|
||||||
|
var page = 1;
|
||||||
|
|
||||||
|
while (page <= MaxPages)
|
||||||
|
{
|
||||||
|
var read = await ReadPageAsync(bearer, page, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var server in read.Servers ?? [])
|
||||||
|
{
|
||||||
|
servers.Add(Convert(server, warnings));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read.Meta?.Pagination?.NextPage is not { } next || next <= page)
|
||||||
|
{
|
||||||
|
return new HetznerImport(servers, warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
page = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
var counted = servers.Count.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
warnings.Add(
|
||||||
|
$"Stopped after {MaxPages.ToString(CultureInfo.InvariantCulture)} pages. This list is the first "
|
||||||
|
+ counted + " servers in the project and not all of them.");
|
||||||
|
|
||||||
|
return new HetznerImport(servers, warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trims the pasted token and refuses one that could not be a header value.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A token copied out of the Hetzner console arrives with a newline behind it often enough to be worth
|
||||||
|
/// handling, and a header value carrying one throws a <see cref="FormatException"/> from inside
|
||||||
|
/// <see cref="HttpClient"/> — a stack trace where the answer is "we trimmed it for you". Anything left
|
||||||
|
/// after the trim that still cannot be a header is refused here, with a sentence, rather than there.
|
||||||
|
/// </remarks>
|
||||||
|
private static string Sanitise(string token)
|
||||||
|
{
|
||||||
|
var trimmed = token?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
if (trimmed.Length == 0)
|
||||||
|
{
|
||||||
|
throw new HetznerCloudException(null, null, "Paste a Hetzner Cloud API token first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var character in trimmed)
|
||||||
|
{
|
||||||
|
if (char.IsWhiteSpace(character) || char.IsControl(character))
|
||||||
|
{
|
||||||
|
throw new HetznerCloudException(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"That does not look like a Hetzner Cloud API token — it has a space or a line break in "
|
||||||
|
+ "the middle of it. A token is one unbroken run of 64 letters and digits.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fetches and parses one page, turning every way it can fail into one exception type.</summary>
|
||||||
|
private async Task<HetznerServersPage> ReadPageAsync(
|
||||||
|
string token,
|
||||||
|
int page,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
string.Create(CultureInfo.InvariantCulture, $"servers?page={page}&per_page={PerPage}"));
|
||||||
|
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
HttpResponseMessage response;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException failure)
|
||||||
|
{
|
||||||
|
throw new HetznerCloudException(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
$"Could not reach {CloudApi.Host}: {failure.Message}",
|
||||||
|
failure);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException failure) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new HetznerCloudException(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
$"{CloudApi.Host} did not answer in time.",
|
||||||
|
failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (response)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw await FailureAsync(response, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await response.Content
|
||||||
|
.ReadFromJsonAsync(HetznerJsonContext.Default.HetznerServersPage, cancellationToken)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? new HetznerServersPage(null, null);
|
||||||
|
}
|
||||||
|
catch (JsonException failure)
|
||||||
|
{
|
||||||
|
throw new HetznerCloudException(
|
||||||
|
response.StatusCode,
|
||||||
|
null,
|
||||||
|
$"{CloudApi.Host} answered with something that is not a server list. That usually means "
|
||||||
|
+ "a proxy answered instead of Hetzner.",
|
||||||
|
failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns a refusal into the sentence the screen shows.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Hetzner's own message is kept and something actionable is put in front of it, because "unable to
|
||||||
|
/// authenticate" on its own does not tell somebody where tokens come from — and the token step is
|
||||||
|
/// exactly where they will be standing when they read it.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<HetznerCloudException> FailureAsync(
|
||||||
|
HttpResponseMessage response,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? code = null;
|
||||||
|
string? detail = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync(HetznerJsonContext.Default.HetznerErrorEnvelope, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
code = body?.Error?.Code;
|
||||||
|
detail = body?.Error?.Message;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Not Hetzner's error shape. The status still says what happened, which is what the switch below
|
||||||
|
// is written against — a proxy's HTML 502 must not lose the 502 while being parsed.
|
||||||
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
// A content type that is not JSON at all, for the same reason.
|
||||||
|
}
|
||||||
|
|
||||||
|
var said = detail is { Length: > 0 } ? $" Hetzner said: {detail}" : string.Empty;
|
||||||
|
var status = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
var summary = response.StatusCode switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.Unauthorized =>
|
||||||
|
"Hetzner did not accept that token. Cloud API tokens are made per project, in the Hetzner "
|
||||||
|
+ "console under Security → API tokens, and the value is shown only once — so a token "
|
||||||
|
+ "that was written down half-copied fails exactly like this.",
|
||||||
|
HttpStatusCode.Forbidden =>
|
||||||
|
"That token is not allowed to read this project's servers. A read-only token is enough; a "
|
||||||
|
+ "token scoped to something narrower is not.",
|
||||||
|
HttpStatusCode.TooManyRequests =>
|
||||||
|
"Hetzner is rate-limiting this token. Give it a minute and start the scan again.",
|
||||||
|
HttpStatusCode.NotFound =>
|
||||||
|
$"{CloudApi.Host} has no server list at that address, which should not happen and probably "
|
||||||
|
+ "means something between here and Hetzner answered instead.",
|
||||||
|
_ when (int)response.StatusCode >= 500 =>
|
||||||
|
$"Hetzner Cloud returned {status}. That is a fault at their end rather than anything about "
|
||||||
|
+ "the token.",
|
||||||
|
_ => $"Hetzner Cloud returned {status}.",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new HetznerCloudException(response.StatusCode, code, summary + said);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns one server as the API describes it into one the import can offer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything the API leaves out is tolerated rather than fatal, and only the name is worth a warning: a
|
||||||
|
/// server with no plan or no image loses a phrase from a note, and a server with no name would arrive as
|
||||||
|
/// a bookmark called nothing.
|
||||||
|
/// </remarks>
|
||||||
|
private static HetznerServer Convert(HetznerServerDto server, List<string> warnings)
|
||||||
|
{
|
||||||
|
var name = server.Name;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
name = string.Create(CultureInfo.InvariantCulture, $"server-{server.Id}");
|
||||||
|
|
||||||
|
warnings.Add($"A server had no name in the project, so it is offered as {name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var privateAddresses = (server.PrivateNet ?? [])
|
||||||
|
.Select(network => network.Ip)
|
||||||
|
.Where(ip => !string.IsNullOrWhiteSpace(ip))
|
||||||
|
.Select(ip => ip!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var labels = (server.Labels ?? new Dictionary<string, string>(StringComparer.Ordinal))
|
||||||
|
.Select(label => label.Value is { Length: > 0 }
|
||||||
|
? $"{label.Key}={label.Value}"
|
||||||
|
: label.Key)
|
||||||
|
.Order(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new HetznerServer(
|
||||||
|
server.Id,
|
||||||
|
name,
|
||||||
|
server.Status is { Length: > 0 } status ? status : "unknown",
|
||||||
|
Blank(server.PublicNet?.IPv4?.Ip),
|
||||||
|
Blank(server.PublicNet?.IPv6?.Ip),
|
||||||
|
privateAddresses,
|
||||||
|
Blank(server.ServerType?.Name),
|
||||||
|
Where(server.Datacenter),
|
||||||
|
Blank(server.Image?.Name),
|
||||||
|
labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Where a server runs, as a person would say it — or null where the API said nothing useful.</summary>
|
||||||
|
private static string? Where(HetznerDatacenterDto? datacenter)
|
||||||
|
{
|
||||||
|
if (datacenter is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = datacenter.Location;
|
||||||
|
var region = Blank(location?.Name) ?? Blank(datacenter.Name);
|
||||||
|
|
||||||
|
var place = (Blank(location?.City), Blank(location?.Country)) switch
|
||||||
|
{
|
||||||
|
({ } city, { } country) => $"{city}, {country}",
|
||||||
|
({ } city, null) => city,
|
||||||
|
(null, { } country) => country,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (region, place) switch
|
||||||
|
{
|
||||||
|
({ } where, { } which) => $"{where} · {which}",
|
||||||
|
({ } where, null) => where,
|
||||||
|
(null, { } which) => which,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Folds an absent value and a present-but-empty one together, which JSON does not.</summary>
|
||||||
|
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One page of <c>GET /v1/servers</c>.</summary>
|
||||||
|
internal sealed record HetznerServersPage(
|
||||||
|
[property: JsonPropertyName("servers")] IReadOnlyList<HetznerServerDto>? Servers,
|
||||||
|
[property: JsonPropertyName("meta")] HetznerMetaDto? Meta);
|
||||||
|
|
||||||
|
/// <summary>The envelope every Hetzner list response carries its paging in.</summary>
|
||||||
|
internal sealed record HetznerMetaDto(
|
||||||
|
[property: JsonPropertyName("pagination")] HetznerPaginationDto? Pagination);
|
||||||
|
|
||||||
|
/// <summary>Only the forward link is read; the rest of Hetzner's paging block is not needed to walk it.</summary>
|
||||||
|
internal sealed record HetznerPaginationDto(
|
||||||
|
[property: JsonPropertyName("next_page")] int? NextPage);
|
||||||
|
|
||||||
|
/// <summary>One server, as the API describes it. Every field is optional because the API's are.</summary>
|
||||||
|
internal sealed record HetznerServerDto(
|
||||||
|
[property: JsonPropertyName("id")] long Id,
|
||||||
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
[property: JsonPropertyName("status")] string? Status,
|
||||||
|
[property: JsonPropertyName("public_net")] HetznerPublicNetDto? PublicNet,
|
||||||
|
[property: JsonPropertyName("private_net")] IReadOnlyList<HetznerPrivateNetDto>? PrivateNet,
|
||||||
|
[property: JsonPropertyName("server_type")] HetznerNamedDto? ServerType,
|
||||||
|
[property: JsonPropertyName("datacenter")] HetznerDatacenterDto? Datacenter,
|
||||||
|
[property: JsonPropertyName("image")] HetznerNamedDto? Image,
|
||||||
|
[property: JsonPropertyName("labels")] IReadOnlyDictionary<string, string>? Labels);
|
||||||
|
|
||||||
|
/// <summary>A server's public addresses. Either half can be absent on a server that has only the other.</summary>
|
||||||
|
internal sealed record HetznerPublicNetDto(
|
||||||
|
[property: JsonPropertyName("ipv4")] HetznerIpDto? IPv4,
|
||||||
|
[property: JsonPropertyName("ipv6")] HetznerIpDto? IPv6);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One public address.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The v6 case puts a <em>network</em> in this field rather than an address — see
|
||||||
|
/// <see cref="HetznerServer.FirstIPv6Address"/>, which is where that is dealt with rather than papered over
|
||||||
|
/// here.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed record HetznerIpDto([property: JsonPropertyName("ip")] string? Ip);
|
||||||
|
|
||||||
|
/// <summary>A server's attachment to one private network.</summary>
|
||||||
|
internal sealed record HetznerPrivateNetDto([property: JsonPropertyName("ip")] string? Ip);
|
||||||
|
|
||||||
|
/// <summary>The shape shared by the server type and the image, of which only the name is read.</summary>
|
||||||
|
internal sealed record HetznerNamedDto([property: JsonPropertyName("name")] string? Name);
|
||||||
|
|
||||||
|
/// <summary>Where a server runs.</summary>
|
||||||
|
internal sealed record HetznerDatacenterDto(
|
||||||
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
[property: JsonPropertyName("location")] HetznerLocationDto? Location);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="HetznerDatacenterDto" />
|
||||||
|
internal sealed record HetznerLocationDto(
|
||||||
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
[property: JsonPropertyName("city")] string? City,
|
||||||
|
[property: JsonPropertyName("country")] string? Country);
|
||||||
|
|
||||||
|
/// <summary>How Hetzner wraps a refusal.</summary>
|
||||||
|
internal sealed record HetznerErrorEnvelope(
|
||||||
|
[property: JsonPropertyName("error")] HetznerErrorDto? Error);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="HetznerErrorEnvelope" />
|
||||||
|
internal sealed record HetznerErrorDto(
|
||||||
|
[property: JsonPropertyName("code")] string? Code,
|
||||||
|
[property: JsonPropertyName("message")] string? Message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The serializer for everything above.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Source-generated rather than reflective, for the reason every other JSON context in this repository is:
|
||||||
|
/// the Android head publishes trimmed, and a reflective deserializer in an assembly the phone links is a
|
||||||
|
/// trim warning at best and a runtime failure at worst.
|
||||||
|
/// </remarks>
|
||||||
|
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||||
|
[JsonSerializable(typeof(HetznerServersPage))]
|
||||||
|
[JsonSerializable(typeof(HetznerErrorEnvelope))]
|
||||||
|
internal sealed partial class HetznerJsonContext : JsonSerializerContext;
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which of a Hetzner server's addresses an import should dial.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One choice for the whole import rather than one per row, for the reason the ssh_config screen's key tick
|
||||||
|
/// is one control: an estate is reached the same way throughout — everything over the public internet, or
|
||||||
|
/// everything across the private network from a machine already inside it — and a per-server dropdown would
|
||||||
|
/// be forty answers to one question.
|
||||||
|
/// </remarks>
|
||||||
|
public enum HetznerAddressKind
|
||||||
|
{
|
||||||
|
/// <summary>The public IPv4 address, which is how all but the cheapest Hetzner servers are reached.</summary>
|
||||||
|
PublicIPv4 = 0,
|
||||||
|
|
||||||
|
/// <summary>The public IPv6 address, for an estate that is v6 throughout — or a server with no v4 at all.</summary>
|
||||||
|
PublicIPv6 = 1,
|
||||||
|
|
||||||
|
/// <summary>The address on the first private network the server is attached to.</summary>
|
||||||
|
Private = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An address an import settled on, and what it had to assume to get there.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Value">What the host will dial.</param>
|
||||||
|
/// <param name="Warning">
|
||||||
|
/// What is not simply a fact the API stated, or null where it is. Every one of these is a place the import
|
||||||
|
/// is deciding something on somebody's behalf, and a bookmark that dials the wrong machine is worse than one
|
||||||
|
/// that was never offered — so they are shown beside the row rather than folded away.
|
||||||
|
/// </param>
|
||||||
|
public sealed record HetznerAddress(string Value, string? Warning);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One server a Hetzner Cloud project holds, as the import sees it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Deliberately not a <see cref="HostSecret"/>, on exactly the reasoning <see cref="ImportedHost"/> gives:
|
||||||
|
/// this is a candidate nobody has agreed to import, and it carries facts a stored host has no field for —
|
||||||
|
/// the server's own id, what it is running, where it runs, and which of its several addresses were on offer.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>There is no key here and there cannot be.</b> The Cloud API will name the SSH keys a project holds and
|
||||||
|
/// hand over their public halves; the private halves have never been in Hetzner's hands. So an imported host
|
||||||
|
/// authenticates with a key that was already in this keychain or it asks for a password, and the import
|
||||||
|
/// screen says so rather than leaving somebody to find out at connect time.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Id">The server's id in the project, which is the one name of it that never changes.</param>
|
||||||
|
/// <param name="Name">What the project calls it, which is what the user will recognise.</param>
|
||||||
|
/// <param name="Status">
|
||||||
|
/// <c>running</c>, <c>off</c>, <c>initializing</c> and the rest. Shown on the row and deliberately not
|
||||||
|
/// stored: it stays true for as long as it takes somebody to press a button in the Hetzner console.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="PublicIPv4">The public IPv4 address, or null on a server that has none.</param>
|
||||||
|
/// <param name="PublicIPv6Network">
|
||||||
|
/// The public IPv6 <em>network</em> — Hetzner routes a whole /64 and the API reports the prefix, not an
|
||||||
|
/// address. See <see cref="FirstIPv6Address"/> for what this import does with that.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="PrivateAddresses">The addresses it has on private networks, in the order the API gave them.</param>
|
||||||
|
/// <param name="ServerType">The plan — <c>cx22</c>, <c>ccx13</c> — or null where the API did not say.</param>
|
||||||
|
/// <param name="Location">Where it runs, as a person would say it: <c>fsn1 · Falkenstein, DE</c>.</param>
|
||||||
|
/// <param name="Image">What it was built from, which is the best available hint at which user logs in.</param>
|
||||||
|
/// <param name="Labels">The project's own labels, as <c>key=value</c>, sorted.</param>
|
||||||
|
public sealed record HetznerServer(
|
||||||
|
long Id,
|
||||||
|
string Name,
|
||||||
|
string Status,
|
||||||
|
string? PublicIPv4,
|
||||||
|
string? PublicIPv6Network,
|
||||||
|
IReadOnlyList<string> PrivateAddresses,
|
||||||
|
string? ServerType,
|
||||||
|
string? Location,
|
||||||
|
string? Image,
|
||||||
|
IReadOnlyList<string> Labels)
|
||||||
|
{
|
||||||
|
/// <summary>Whether the API gave this server a public IPv4 address at all.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Not a formality. Hetzner bills IPv4 separately now, and a project built since then can be entirely
|
||||||
|
/// IPv6-only — which is exactly the case an import that assumed a v4 would get silently wrong.
|
||||||
|
/// </remarks>
|
||||||
|
public bool HasPublicIPv4 => PublicIPv4 is { Length: > 0 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The address inside the routed /64 that a stock Hetzner image actually answers on.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>This is a convention and not a fact the API stated</b>, which is why every use of it carries a
|
||||||
|
/// warning. What the API reports is <c>2a01:4f8:1c17:d00::/64</c> — a network, which nothing can dial.
|
||||||
|
/// The address Hetzner's own images configure is the first one in it, and the console shows the same
|
||||||
|
/// thing; a server whose operator moved it elsewhere in the /64 will not answer here.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Computed by setting the low byte rather than by string surgery on the prefix, so a network the API
|
||||||
|
/// spells some other way still yields an address rather than a malformed string.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public string? FirstIPv6Address
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (PublicIPv6Network is not { Length: > 0 } network)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var slash = network.IndexOf('/', StringComparison.Ordinal);
|
||||||
|
var prefix = slash < 0 ? network : network[..slash];
|
||||||
|
|
||||||
|
if (!IPAddress.TryParse(prefix, out var parsed)
|
||||||
|
|| parsed.AddressFamily != AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = parsed.GetAddressBytes();
|
||||||
|
bytes[^1] = 1;
|
||||||
|
|
||||||
|
return new IPAddress(bytes).ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settles on the address this server would be dialled at, or null where the choice has none to offer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The two public choices fall back to each other and the private one does not, and that asymmetry is
|
||||||
|
/// deliberate. Falling back between v4 and v6 reaches the same machine over the same internet, so the
|
||||||
|
/// worst case is a route somebody did not pick; falling back from a private address to a public one
|
||||||
|
/// would put an address on the open internet into a bookmark that was asked to stay off it. So a server
|
||||||
|
/// with no private address is simply not importable under that choice, and the screen says which.
|
||||||
|
/// </remarks>
|
||||||
|
public HetznerAddress? Resolve(HetznerAddressKind kind)
|
||||||
|
{
|
||||||
|
var six = FirstIPv6Address;
|
||||||
|
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
HetznerAddressKind.PublicIPv4 when HasPublicIPv4 => new(PublicIPv4!, null),
|
||||||
|
HetznerAddressKind.PublicIPv4 when six is not null => new(
|
||||||
|
six,
|
||||||
|
$"No public IPv4. This is the first address of {PublicIPv6Network}, which is where Hetzner's "
|
||||||
|
+ "own images put the server — the API reports the network, not an address."),
|
||||||
|
HetznerAddressKind.PublicIPv6 when six is not null => new(
|
||||||
|
six,
|
||||||
|
$"The API reports {PublicIPv6Network} rather than an address. This is the first address in "
|
||||||
|
+ "it, which is where Hetzner's own images put the server."),
|
||||||
|
HetznerAddressKind.PublicIPv6 when HasPublicIPv4 => new(
|
||||||
|
PublicIPv4!,
|
||||||
|
"No public IPv6 on this server, so this is its IPv4 address."),
|
||||||
|
HetznerAddressKind.Private when PrivateAddresses.Count > 1 => new(
|
||||||
|
PrivateAddresses[0],
|
||||||
|
$"On {PrivateAddresses.Count} private networks. This is the first the API listed."),
|
||||||
|
HetznerAddressKind.Private when PrivateAddresses.Count == 1 => new(PrivateAddresses[0], null),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Turns this into the host that would be stored.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing here authenticates.</b> There is no key to bring — see the remark on this record — so the
|
||||||
|
/// host arrives bound to nothing and asks for a password until somebody points it at a key already in
|
||||||
|
/// the keychain. That is the same shape an <c>ssh_config</c> import has with its key tick off, and it is
|
||||||
|
/// stated on the screen rather than discovered at connect time.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What goes in the notes is what does not go stale.</b> The id, the plan, the location and the image
|
||||||
|
/// survive; the power state does not, so it is a column on the import screen and never a note. A note
|
||||||
|
/// saying "running" about a machine somebody switched off last March is worse than no note.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="address">The address to dial, from <see cref="Resolve"/>.</param>
|
||||||
|
/// <param name="username">The login user, or null and empty for none.</param>
|
||||||
|
/// <param name="port">The SSH port.</param>
|
||||||
|
public HostSecret ToSecret(HetznerAddress address, string? username, int port)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(address);
|
||||||
|
|
||||||
|
return new HostSecret
|
||||||
|
{
|
||||||
|
Label = Name,
|
||||||
|
Hostname = address.Value,
|
||||||
|
Port = port,
|
||||||
|
Username = string.IsNullOrWhiteSpace(username) ? null : username.Trim(),
|
||||||
|
Notes = Describe(address),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the note says about where this came from, plus whatever the address had to assume.</summary>
|
||||||
|
private string Describe(HetznerAddress address)
|
||||||
|
{
|
||||||
|
var facts = new List<string> { string.Create(CultureInfo.InvariantCulture, $"server {Id}") };
|
||||||
|
|
||||||
|
if (ServerType is { Length: > 0 } plan)
|
||||||
|
{
|
||||||
|
facts.Add(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Location is { Length: > 0 } where)
|
||||||
|
{
|
||||||
|
facts.Add(where);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Image is { Length: > 0 } image)
|
||||||
|
{
|
||||||
|
facts.Add($"image {image}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Labels.Count > 0)
|
||||||
|
{
|
||||||
|
facts.Add($"labels {string.Join(", ", Labels)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sentence = $"Imported from Hetzner Cloud: {string.Join(", ", facts)}.";
|
||||||
|
|
||||||
|
return address.Warning is { Length: > 0 } warning ? $"{sentence} {warning}" : sentence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything a Hetzner Cloud project yielded: the servers it can offer, and what it could not.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Servers">The importable candidates, in the order the API returned them.</param>
|
||||||
|
/// <param name="Warnings">
|
||||||
|
/// What the read could not represent, at project level — a page that came back malformed, a server the API
|
||||||
|
/// described without a name. The counterpart of <see cref="SshConfigImport.Warnings"/>.
|
||||||
|
/// </param>
|
||||||
|
public sealed record HetznerImport(IReadOnlyList<HetznerServer> Servers, IReadOnlyList<string> Warnings);
|
||||||
@@ -102,6 +102,26 @@ public sealed record ClientPaths(string DataDirectory)
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string DeviceKeyFile => Path.Combine(DataDirectory, "device.key");
|
public string DeviceKeyFile => Path.Combine(DataDirectory, "device.key");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file one desktop instance holds open for as long as it is running.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Named here rather than in the head that uses it, because every other name in this directory is named
|
||||||
|
/// here and a filename invented somewhere else is one nobody looking at the profile can account for. The
|
||||||
|
/// Android head has no use for it: the platform allows one process per package and needs no help.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Being in the profile directory is what scopes it correctly, and that is the whole reason it is a
|
||||||
|
/// file rather than a named mutex.</b> This path is already per user and per channel, so a nightly and a
|
||||||
|
/// release build do not exclude each other and two people signed into one machine each get their own
|
||||||
|
/// instance — neither of which a global name would give without repeating the same two decisions in
|
||||||
|
/// another form. Contents are irrelevant and it stays empty; what carries the meaning is the exclusive
|
||||||
|
/// handle, which the operating system releases when the process ends however it ends.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
|
||||||
|
|
||||||
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
||||||
|
|||||||
@@ -0,0 +1,863 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using DodoSSH.Client.Import;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>Which of the wizard's three steps is on screen.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A wizard rather than one screen, unlike <see cref="ImportViewModel"/>, and the reason is what each step
|
||||||
|
/// costs. Reading an <c>ssh_config</c> is free and local, so that screen scans the moment it opens and shows
|
||||||
|
/// a list. This one cannot: it needs a credential the user has to go and make in somebody else's console,
|
||||||
|
/// and it then makes a request over the internet. Those are two different moments, and a screen that mixed
|
||||||
|
/// the token box in with the list would ask for the credential again every time somebody came back to look
|
||||||
|
/// at the list.
|
||||||
|
/// </remarks>
|
||||||
|
internal enum HetznerImportStep
|
||||||
|
{
|
||||||
|
/// <summary>Paste a token. Nothing has been sent yet.</summary>
|
||||||
|
Token = 0,
|
||||||
|
|
||||||
|
/// <summary>The project's servers, and what to do with them. Nothing has been stored yet.</summary>
|
||||||
|
Choose = 1,
|
||||||
|
|
||||||
|
/// <summary>What was stored.</summary>
|
||||||
|
Done = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One Hetzner server, as a row somebody decides about.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The address is not a property of the server but of the server <em>and the choice above the list</em>, so
|
||||||
|
/// it is pushed down by <see cref="Refresh"/> rather than computed here — the same shape
|
||||||
|
/// <see cref="ImportRowViewModel.ImportsItsKey"/> has, and for the same reason: a control that decides one
|
||||||
|
/// thing for forty rows belongs above the forty rows.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class HetznerRowViewModel(HetznerServer server) : ObservableObject
|
||||||
|
{
|
||||||
|
private HetznerAddress? resolved;
|
||||||
|
|
||||||
|
internal HetznerServer Server => server;
|
||||||
|
|
||||||
|
internal string Name => server.Name;
|
||||||
|
|
||||||
|
/// <summary>Whether it is running, off, or somewhere in between — a fact of the moment, never stored.</summary>
|
||||||
|
internal string Status => server.Status;
|
||||||
|
|
||||||
|
/// <summary>The plan and where it runs, as one cell.</summary>
|
||||||
|
internal string Kind => (server.ServerType, server.Location) switch
|
||||||
|
{
|
||||||
|
({ Length: > 0 } plan, { Length: > 0 } place) => $"{plan} · {place}",
|
||||||
|
({ Length: > 0 } plan, _) => plan,
|
||||||
|
(_, { Length: > 0 } place) => place,
|
||||||
|
_ => "—",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>What this row would dial, or an em dash where the address choice leaves it nothing.</summary>
|
||||||
|
internal string Address => resolved?.Value ?? "—";
|
||||||
|
|
||||||
|
/// <summary>Whether the address choice above the list can reach this server at all.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// False is the private-network case: a server on no private network has no private address, and this
|
||||||
|
/// import will not quietly fall back to a public one. Such a row cannot be ticked, which is what stops
|
||||||
|
/// an import that was asked to stay off the internet from putting a public address in the keychain.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool CanImport => resolved is not null;
|
||||||
|
|
||||||
|
/// <summary>Whether the keychain already has a host pointing where this one would.</summary>
|
||||||
|
internal bool AlreadyPresent { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>What had to be assumed to reach that address, if anything.</summary>
|
||||||
|
internal string Warning => resolved?.Warning ?? string.Empty;
|
||||||
|
|
||||||
|
internal bool HasWarning => resolved?.Warning is { Length: > 0 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The row's own chip: the single most actionable thing true about it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Ordered by what a person has to do something about. "No address" means this row cannot be imported
|
||||||
|
/// at all and wins outright; a warning is a fact about where it will connect and wins over "already
|
||||||
|
/// here", which is only about whether it is worth ticking. Deliberately parallel to
|
||||||
|
/// <see cref="ImportRowViewModel.Meaning"/> — the same three states plus the one that screen cannot
|
||||||
|
/// have, because a resolved <c>ssh_config</c> entry always has a hostname.
|
||||||
|
/// </remarks>
|
||||||
|
internal string Meaning => (CanImport, HasWarning, AlreadyPresent) switch
|
||||||
|
{
|
||||||
|
(false, _, _) => "no address of that kind",
|
||||||
|
(_, true, _) => Warning,
|
||||||
|
(_, _, true) => "already here",
|
||||||
|
_ => "new host",
|
||||||
|
};
|
||||||
|
|
||||||
|
internal bool IsMeaningWarned => !CanImport || HasWarning;
|
||||||
|
|
||||||
|
internal bool IsMeaningExisting => CanImport && !HasWarning && AlreadyPresent;
|
||||||
|
|
||||||
|
internal bool IsMeaningNew => CanImport && !HasWarning && !AlreadyPresent;
|
||||||
|
|
||||||
|
/// <summary>The address, resolved, or null where there is none of the chosen kind.</summary>
|
||||||
|
internal HetznerAddress? Resolved => resolved;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isSelected;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-resolves this row against the address choice above the list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="kind">Which address the import is dialling.</param>
|
||||||
|
/// <param name="isAlreadyPresent">Whether the keychain already holds a host at a given address.</param>
|
||||||
|
/// <param name="resetSelection">
|
||||||
|
/// Whether to put the tick back to its default. True on a fresh scan and after an import — where the
|
||||||
|
/// question "which of these do you want" is being put again — and false when only the address choice
|
||||||
|
/// moved, because throwing away somebody's ticks because they changed a dropdown would be its own bug.
|
||||||
|
/// A row that has lost its address is unticked either way; there is nothing to import.
|
||||||
|
/// </param>
|
||||||
|
internal void Refresh(
|
||||||
|
HetznerAddressKind kind,
|
||||||
|
Func<HetznerAddress, bool> isAlreadyPresent,
|
||||||
|
bool resetSelection)
|
||||||
|
{
|
||||||
|
resolved = server.Resolve(kind);
|
||||||
|
AlreadyPresent = resolved is not null && isAlreadyPresent(resolved);
|
||||||
|
|
||||||
|
if (resetSelection)
|
||||||
|
{
|
||||||
|
// A server the keychain already points at starts unticked, exactly as the ssh_config import's
|
||||||
|
// rows do: importing it again is allowed — a second bookmark for one machine is a thing people
|
||||||
|
// want — but it should take a click rather than be the default.
|
||||||
|
IsSelected = CanImport && !AlreadyPresent;
|
||||||
|
}
|
||||||
|
else if (!CanImport)
|
||||||
|
{
|
||||||
|
IsSelected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(Address));
|
||||||
|
OnPropertyChanged(nameof(CanImport));
|
||||||
|
OnPropertyChanged(nameof(AlreadyPresent));
|
||||||
|
OnPropertyChanged(nameof(Warning));
|
||||||
|
OnPropertyChanged(nameof(HasWarning));
|
||||||
|
OnPropertyChanged(nameof(Meaning));
|
||||||
|
OnPropertyChanged(nameof(IsMeaningWarned));
|
||||||
|
OnPropertyChanged(nameof(IsMeaningExisting));
|
||||||
|
OnPropertyChanged(nameof(IsMeaningNew));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One entry in the import's key picker: a key already in the keychain, or none at all.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="KeyId">The key, or null for "no key".</param>
|
||||||
|
/// <param name="VaultId">The vault that key lives in, or null for the "no key" entry.</param>
|
||||||
|
/// <param name="Label">What the picker shows.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>An id rather than the key, because nothing here reads key material.</b> The import writes
|
||||||
|
/// <c>HostSecret.SshKeyId</c> and stops; the private half stays where it already was, in the one vault
|
||||||
|
/// entry every host that uses it points at. That is the same argument <c>VaultViewModel.ImportHostsAsync</c>
|
||||||
|
/// makes for storing one key per file rather than one per host, arrived at from the other end: here there
|
||||||
|
/// is no file at all, only a key the keychain has had all along.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A sentinel entry for "no key" rather than a null selection, for the reason <c>GroupChoice.None</c> gives:
|
||||||
|
/// a picker with nothing selected and a picker meaning "ask for a password" look identical and are not the
|
||||||
|
/// same thing — and on this screen the second is a promise about how forty machines will behave.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed record HetznerKeyChoice(Guid? KeyId, Guid? VaultId, string Label)
|
||||||
|
{
|
||||||
|
/// <summary>The "bind nothing" entry, always first and selected until somebody chooses otherwise.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The default, and deliberately: pointing an estate at a key is a claim about which key opens those
|
||||||
|
/// machines, and Hetzner cannot tell this screen that — the API names a project's keys but has never
|
||||||
|
/// held a private half. Guessed wrong it is forty hosts that fail to authenticate with a reason nobody
|
||||||
|
/// typed. Chosen, it is one answer to one question the person running the import already knows.
|
||||||
|
/// </remarks>
|
||||||
|
internal static HetznerKeyChoice None { get; } = new(null, null, "No key — ask for a password");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Importing the servers in a Hetzner Cloud project.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Three steps, and the first two write nothing.</b> Step one takes a token and sends one request; step
|
||||||
|
/// two shows what the project holds and lets somebody choose; step three says what was stored. The split is
|
||||||
|
/// the same argument <see cref="ImportViewModel"/> makes — a project with forty servers is forty questions,
|
||||||
|
/// and only the person reading the list knows which of them they want bookmarked — with one addition this
|
||||||
|
/// import has and that one does not: the credential. It has to be fetched from another company's console
|
||||||
|
/// before anything here can happen at all, which is a step whether or not it is drawn as one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The token is never stored.</b> It lives in <see cref="Token"/> while the wizard is open and is cleared
|
||||||
|
/// the moment the read is done. Not in the vault, not in settings, not in a log — the next import asks
|
||||||
|
/// again. That is the same reasoning <see cref="ImportViewModel.ImportsKeys"/> gives for not remembering its
|
||||||
|
/// tick: a remembered answer is an answer to a question that was never put twice, and this one would be a
|
||||||
|
/// stored credential capable of listing somebody's whole estate.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing imported here authenticates.</b> Hetzner has never held the private half of an SSH key, so
|
||||||
|
/// every host arrives bound to nothing and asks for a password until somebody points it at a key already in
|
||||||
|
/// the keychain. The screen says so before the button rather than leaving it to be discovered at connect
|
||||||
|
/// time — see <see cref="AuthenticationNote"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class HetznerImportViewModel(
|
||||||
|
VaultViewModel vault,
|
||||||
|
HetznerCloudClient hetzner,
|
||||||
|
Action? onCancel = null) : ObservableObject
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The user a stock Hetzner Linux image logs in as.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Every image Hetzner publishes puts the project's SSH keys on root and no other account exists yet, so
|
||||||
|
/// this is right far more often than it is wrong — and where it is wrong, it is wrong for a whole
|
||||||
|
/// estate at once, which is why the box is above the list rather than on each row.
|
||||||
|
/// </remarks>
|
||||||
|
internal const string DefaultUsername = "root";
|
||||||
|
|
||||||
|
internal ObservableCollection<HetznerRowViewModel> Rows { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>What the project could not be made to say, at project level.</summary>
|
||||||
|
internal ObservableCollection<string> Warnings { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private HetznerImportStep step = HetznerImportStep.Token;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Cloud API token, held only for as long as the wizard needs it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A plain bound string, like the passphrase box on the unlock screen, and it carries the same caveat:
|
||||||
|
/// a .NET string cannot be zeroed, so what this really promises is that nothing here writes it anywhere
|
||||||
|
/// that outlives the process. <see cref="ScanAsync"/> clears it as soon as the read is finished.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string token = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string status = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isBusy;
|
||||||
|
|
||||||
|
/// <summary>The login user every imported host gets.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string username = DefaultUsername;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The SSH port, as typed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A string rather than an int because a text box passes through "" and "2" on the way to "22", and a
|
||||||
|
/// bound int would either refuse the keystroke or silently substitute a number nobody typed. Parsed by
|
||||||
|
/// <see cref="Port"/>, which is also what <see cref="IsPortValid"/> reports on.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string portText = HetznerPort.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private const int HetznerPort = 22;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private HetznerAddressKind addressKind = HetznerAddressKind.PublicIPv4;
|
||||||
|
|
||||||
|
internal bool IsTokenStep => Step is HetznerImportStep.Token;
|
||||||
|
|
||||||
|
internal bool IsChooseStep => Step is HetznerImportStep.Choose;
|
||||||
|
|
||||||
|
internal bool IsDoneStep => Step is HetznerImportStep.Done;
|
||||||
|
|
||||||
|
/// <summary>"Step 2 of 3", for the header — a wizard that does not say how long it is is a maze.</summary>
|
||||||
|
internal string StepLabel => $"STEP {(int)Step + 1} OF 3";
|
||||||
|
|
||||||
|
/// <summary>What this step is for, in the header, beside <see cref="StepLabel"/>.</summary>
|
||||||
|
internal string StepTitle => Step switch
|
||||||
|
{
|
||||||
|
HetznerImportStep.Token => "Connect to a project",
|
||||||
|
HetznerImportStep.Choose => "Choose what to import",
|
||||||
|
_ => "Imported",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which of the three address chips above the list is lit.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Read-only bools beside a command that sets the enum, rather than two-way bound ones, because the
|
||||||
|
/// control is a chip that runs <see cref="UseAddressCommand"/> — see <c>HetznerImportScreen.axaml</c>
|
||||||
|
/// for why chips rather than radio buttons, which this application has no style for.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool UsesPublicIPv4 => AddressKind is HetznerAddressKind.PublicIPv4;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="UsesPublicIPv4" />
|
||||||
|
internal bool UsesPublicIPv6 => AddressKind is HetznerAddressKind.PublicIPv6;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="UsesPublicIPv4" />
|
||||||
|
internal bool UsesPrivate => AddressKind is HetznerAddressKind.Private;
|
||||||
|
|
||||||
|
/// <summary>What the chosen address means for the list, said once above it rather than per row.</summary>
|
||||||
|
internal string AddressNote => AddressKind switch
|
||||||
|
{
|
||||||
|
HetznerAddressKind.PublicIPv4 =>
|
||||||
|
"The public IPv4 address, which is how all but the cheapest Hetzner servers are reached. A "
|
||||||
|
+ "server with no IPv4 falls back to its IPv6 and the row says so.",
|
||||||
|
HetznerAddressKind.PublicIPv6 =>
|
||||||
|
"The public IPv6 address. Hetzner routes a whole /64 and the API reports the network rather "
|
||||||
|
+ "than an address, so this is the first address in it — where Hetzner's own images put "
|
||||||
|
+ "the server, and not something the API states.",
|
||||||
|
_ =>
|
||||||
|
"The address on the server's own private network, which only reaches it from inside that "
|
||||||
|
+ "network. Servers on no private network are not importable under this choice, and this "
|
||||||
|
+ "will never quietly fall back to a public address.",
|
||||||
|
};
|
||||||
|
|
||||||
|
internal bool HasRows => Rows.Count > 0;
|
||||||
|
|
||||||
|
internal bool HasWarnings => Warnings.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>The port every imported host gets, or null where what is typed is not one.</summary>
|
||||||
|
internal int? Port =>
|
||||||
|
int.TryParse(PortText, NumberStyles.None, CultureInfo.InvariantCulture, out var port)
|
||||||
|
&& port is >= 1 and <= 65535
|
||||||
|
? port
|
||||||
|
: null;
|
||||||
|
|
||||||
|
internal bool IsPortValid => Port is not null;
|
||||||
|
|
||||||
|
internal int SelectedCount => Rows.Count(row => row.IsSelected && row.CanImport);
|
||||||
|
|
||||||
|
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 SERVER" : $"IMPORT {SelectedCount} SERVERS";
|
||||||
|
|
||||||
|
/// <summary>How many rows the address choice can actually reach.</summary>
|
||||||
|
internal int ImportableCount => Rows.Count(row => row.CanImport);
|
||||||
|
|
||||||
|
/// <summary>Whether every importable row is ticked — what the header's tick-all box shows.</summary>
|
||||||
|
internal bool AllTicked => ImportableCount > 0 && SelectedCount == ImportableCount;
|
||||||
|
|
||||||
|
/// <summary>Whether IMPORT would do anything.</summary>
|
||||||
|
internal bool CanImport => SelectedCount > 0 && IsPortValid && !IsBusy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the imported hosts go — see <see cref="VaultViewModel.ImportHostsAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>A picker rather than the active vault, which is what the ssh_config importer still uses.</b>
|
||||||
|
/// The two are not the same question. That screen reads <c>~/.ssh/config</c> — this machine's own
|
||||||
|
/// account, on this machine — and "wherever I am standing" is a fair answer. This one reads a whole
|
||||||
|
/// project out of a company's console, and a project is exactly the thing a team shares: forty servers
|
||||||
|
/// filed into somebody's personal vault by default is either forty items nobody else can see, or forty
|
||||||
|
/// items moved one at a time afterwards.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Its own selection rather than <see cref="VaultViewModel.TargetVaults"/>'s, on the reasoning
|
||||||
|
/// <c>VaultViewModel.EditorVaultChoices</c> gives for the host editor's: that one is a standing
|
||||||
|
/// preference about where new items go, and this is a field of the import in front of you. Filled from
|
||||||
|
/// the same list, so it offers what every other "file this into" control offers — vaults this session
|
||||||
|
/// can both read and write.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal ObservableCollection<VaultChoiceViewModel> VaultChoices { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private VaultChoiceViewModel? selectedVault;
|
||||||
|
|
||||||
|
/// <summary>Whether there is more than one vault to choose between.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Hidden entirely at one rather than shown disabled, as the keychain screen's own picker is: a control
|
||||||
|
/// offering one option is a question with no answer.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool HasVaultChoice => VaultChoices.Count > 1;
|
||||||
|
|
||||||
|
/// <summary>The vault the import writes to, or null for the active one.</summary>
|
||||||
|
internal Guid? TargetVaultId => SelectedVault?.VaultId;
|
||||||
|
|
||||||
|
/// <summary>What the summary line calls the vault the hosts are going into.</summary>
|
||||||
|
internal string VaultName => SelectedVault?.Name ?? vault.VaultName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keys every imported host could be bound to: "no key", then everything in the keychain.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The one thing this import could never do, done by pointing rather than by bringing.</b> Hetzner
|
||||||
|
/// has never held a private half, so no key can arrive <em>with</em> these hosts — but a key that is
|
||||||
|
/// already in the keychain is almost always the key that opens them, because it is the one whose public
|
||||||
|
/// half was handed to Hetzner when the servers were created. Binding it here is one answer for the whole
|
||||||
|
/// estate; without it, it was forty visits to forty host editors.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Every readable vault's keys, not the target vault's. A binding resolves across vaults — that is what
|
||||||
|
/// makes one key on twenty hosts in three vaults the arrangement keys exist for — so narrowing this to
|
||||||
|
/// the destination would refuse a working answer. What it costs is a host whose colleagues cannot
|
||||||
|
/// resolve its key, and that is said in <see cref="AuthenticationNote"/> rather than prevented here.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal ObservableCollection<HetznerKeyChoice> KeyChoices { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private HetznerKeyChoice? selectedKey = HetznerKeyChoice.None;
|
||||||
|
|
||||||
|
/// <summary>Whether the keychain has a key to offer at all.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The picker goes entirely on a keychain with no keys in it, rather than standing there offering only
|
||||||
|
/// "no key" — which is a question with one answer, and an invitation to go looking for a control that
|
||||||
|
/// would do something.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool HasKeyChoice => KeyChoices.Count > 1;
|
||||||
|
|
||||||
|
/// <summary>The key every imported host is bound to, where one was chosen.</summary>
|
||||||
|
internal Guid? BoundKeyId => SelectedKey?.KeyId;
|
||||||
|
|
||||||
|
/// <summary>How many ticked, out of how many, and where they land.</summary>
|
||||||
|
internal string SelectionSummary
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var noun = Rows.Count == 1 ? "server" : "servers";
|
||||||
|
|
||||||
|
return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The sentence that says how an imported host will authenticate.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Said before the button rather than after, on exactly the reasoning <c>ImportScreen.axaml</c> gives
|
||||||
|
/// for its own version: whether the credential comes with the host is the difference between a bookmark
|
||||||
|
/// that connects and one that asks for a password, and somebody who is not told will conclude the
|
||||||
|
/// import was broken.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Two sentences now rather than one, because the answer is a choice. The first half of the original —
|
||||||
|
/// that Hetzner cannot hand over a private half — is true either way and stays in both, since it is the
|
||||||
|
/// fact that explains why the key had to be picked from a list instead of arriving with the servers.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal string AuthenticationNote => SelectedKey?.KeyId is null
|
||||||
|
? "Hetzner has never held the private half of an SSH key, so nothing here can bring one. With no key "
|
||||||
|
+ "chosen above, a server imported this way arrives asking for a password until you bind it to "
|
||||||
|
+ "one — from the host's own editor, or in bulk from the Hosts screen."
|
||||||
|
: $"Hetzner has never held the private half of an SSH key, so nothing here brings one: every server "
|
||||||
|
+ $"imported is pointed at '{SelectedKey.Label}', which was already in your keychain. The key "
|
||||||
|
+ $"itself is not copied and not read.{StrandedKeyNote}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What is said when the chosen key sits in a different vault from the hosts.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same fact the move panel names when a host leaves its key behind, and it matters more here
|
||||||
|
/// because it is being done to a whole estate at once: a binding resolves across every vault
|
||||||
|
/// <em>this</em> account can read, so the import connects perfectly from this machine and not at all
|
||||||
|
/// for a colleague who holds only the destination's key. Stated rather than prevented — one key used by
|
||||||
|
/// hosts in three vaults is what keys are for.
|
||||||
|
/// </remarks>
|
||||||
|
private string StrandedKeyNote =>
|
||||||
|
SelectedKey?.VaultId is { } keyVault && TargetVaultId is { } target && keyVault != target
|
||||||
|
? $" That key lives in another vault than {VaultName}. It resolves for you, so these hosts will "
|
||||||
|
+ $"connect here — but anyone who can read {VaultName} and not the key's own vault will "
|
||||||
|
+ "see hosts they cannot authenticate."
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
/// <summary>What the token step says about the token it is asking for.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Three facts, each of which somebody would otherwise have to learn the hard way: a token is per
|
||||||
|
/// project rather than per account, read-only is enough, and nothing here keeps it. The first is the one
|
||||||
|
/// that surprises people — an account with four projects runs this four times, and a wizard that did not
|
||||||
|
/// say so would look like it had lost most of the estate.
|
||||||
|
/// </remarks>
|
||||||
|
internal static string TokenNote =>
|
||||||
|
"A Cloud API token belongs to one project, so this imports one project's servers — an account with "
|
||||||
|
+ "several runs this once per project. Make one in the Hetzner console under Security → API "
|
||||||
|
+ "tokens; Read is the only permission this needs, and the token is used for a single request "
|
||||||
|
+ "and then forgotten. Dedicated servers live behind Hetzner's Robot instead and are not "
|
||||||
|
+ "covered here.";
|
||||||
|
|
||||||
|
/// <summary>Reads the project. Writes nothing, and forgets the token on the way out.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ClearRows();
|
||||||
|
RefreshChoices();
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Fill(await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true));
|
||||||
|
|
||||||
|
if (Rows.Count == 0)
|
||||||
|
{
|
||||||
|
// Deliberately not an error, and deliberately not a step forward: an empty project is a
|
||||||
|
// real answer to a correct token, and a list screen with nothing on it says less than this.
|
||||||
|
Status = "That project has no servers in it.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status = Rows.Count == 1
|
||||||
|
? "Found 1 server. Nothing is stored until you press the button below."
|
||||||
|
: $"Found {Rows.Count} servers. Nothing is stored until you press the button below.";
|
||||||
|
|
||||||
|
Step = HetznerImportStep.Choose;
|
||||||
|
}
|
||||||
|
catch (HetznerCloudException failure)
|
||||||
|
{
|
||||||
|
// Stays on the token step, which is where the box is. See HetznerCloudException.IsTokenProblem
|
||||||
|
// for why a fault at Hetzner's end is worded differently from a refused token even though both
|
||||||
|
// land here: one of them is something the person reading this can act on.
|
||||||
|
Status = failure.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Cleared whatever happened, including on the way to a list somebody is about to import from:
|
||||||
|
// the read is over and the token has no further use. A failed attempt clears it too, so a
|
||||||
|
// mistyped token is retyped rather than corrected in place — the lesser of the two annoyances
|
||||||
|
// against a credential sitting in a text box for the rest of the session.
|
||||||
|
Token = string.Empty;
|
||||||
|
|
||||||
|
IsBusy = false;
|
||||||
|
RaiseListState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stores the ticked servers as hosts.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ImportAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Port is not { } port)
|
||||||
|
{
|
||||||
|
Status = "That is not a port. Give a number between 1 and 65535.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chosen = Rows.Where(row => row.IsSelected && row.CanImport).ToList();
|
||||||
|
|
||||||
|
if (chosen.Count == 0)
|
||||||
|
{
|
||||||
|
Status = "Nothing is ticked.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Key and KeyPath stay null and the binding goes on the host itself: those two are for key
|
||||||
|
// material arriving with an import, and there is none — see HetznerKeyChoice. A null BoundKeyId
|
||||||
|
// leaves the host bound to nothing, which is what it always was.
|
||||||
|
var requests = chosen
|
||||||
|
.Select(row => new ImportedHostRequest(
|
||||||
|
row.Server.ToSecret(row.Resolved!, Username, port) with { SshKeyId = BoundKeyId },
|
||||||
|
Key: null,
|
||||||
|
KeyPath: null))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var outcome = await vault.ImportHostsAsync(requests, TargetVaultId, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
// Re-resolved rather than cleared, so a row that was just imported now says "already here" —
|
||||||
|
// which is what makes coming back to the list and importing the rest of the project harmless
|
||||||
|
// and visible rather than harmless and confusing.
|
||||||
|
Refresh(resetSelection: true);
|
||||||
|
|
||||||
|
Status = WhatWasStored(outcome.Hosts);
|
||||||
|
|
||||||
|
Step = HetznerImportStep.Done;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
RaiseListState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Back to the token step, with the list dropped.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The list goes because the token did. Nothing here can refresh what it is showing without asking for a
|
||||||
|
/// credential again, and a "back" that left forty rows on screen over a step that no longer has the
|
||||||
|
/// means to have produced them would be showing a list it cannot stand behind.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void Restart()
|
||||||
|
{
|
||||||
|
ClearRows();
|
||||||
|
Status = string.Empty;
|
||||||
|
Step = HetznerImportStep.Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Back from the last step to the list, with what was just stored marked as already here.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Unlike <see cref="Restart"/> this keeps the rows, and it can: they were read before the token was
|
||||||
|
/// cleared and nothing about them has to be fetched again. What it does not do is claim they are fresh —
|
||||||
|
/// a server created in the Hetzner console since the scan is not in this list and no screen here says
|
||||||
|
/// otherwise.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void Review()
|
||||||
|
{
|
||||||
|
if (Rows.Count > 0)
|
||||||
|
{
|
||||||
|
Step = HetznerImportStep.Choose;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Points the whole import at one of the three kinds of address.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void UseAddress(HetznerAddressKind kind) => AddressKind = kind;
|
||||||
|
|
||||||
|
/// <summary>Ticks or unticks every importable row at once.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleAll()
|
||||||
|
{
|
||||||
|
var importable = Rows.Where(row => row.CanImport).ToList();
|
||||||
|
var target = SelectedCount < importable.Count;
|
||||||
|
|
||||||
|
foreach (var row in importable)
|
||||||
|
{
|
||||||
|
row.IsSelected = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
RaiseListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The footer's Cancel button: back to the Preferences page, nothing stored.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A delegate rather than a reference up to <c>MainWindowViewModel</c>, on the reasoning
|
||||||
|
/// <see cref="ImportViewModel"/>'s own cancel gives: this type has no business knowing settings mode
|
||||||
|
/// exists, and a null delegate — nothing wired, as in a test building this directly — makes the button a
|
||||||
|
/// no-op rather than a crash.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void Cancel() => onCancel?.Invoke();
|
||||||
|
|
||||||
|
/// <summary>What the import did, in the sentence the screen keeps after it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This screen's own sentence rather than the vault's, which the push that follows an import overwrites
|
||||||
|
/// with a sync report — right on a screen somebody is about to leave, wrong on the one they are still
|
||||||
|
/// standing on. It names the vault and the key because those are the two answers that were chosen above
|
||||||
|
/// rather than assumed: which vault is what somebody will look for on the Hosts screen, and whether
|
||||||
|
/// there is a key is what they would otherwise find out at the next connection.
|
||||||
|
/// </remarks>
|
||||||
|
private string WhatWasStored(int hosts) => (hosts == 1, SelectedKey?.KeyId is not null) switch
|
||||||
|
{
|
||||||
|
(true, true) =>
|
||||||
|
$"Imported 1 server into {VaultName}. It is on the Hosts screen, bound to '{SelectedKey!.Label}'.",
|
||||||
|
(true, false) =>
|
||||||
|
$"Imported 1 server into {VaultName}. It is on the Hosts screen, asking for a password until "
|
||||||
|
+ "you bind it to a key.",
|
||||||
|
(false, true) =>
|
||||||
|
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, bound to "
|
||||||
|
+ $"'{SelectedKey!.Label}'.",
|
||||||
|
_ =>
|
||||||
|
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, asking for a "
|
||||||
|
+ "password until you bind them to a key.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Fills the list from what the project said, and resolves every row against the choices.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Its own method rather than part of <see cref="ScanAsync"/>, which is a request, a failure path and a
|
||||||
|
/// credential to forget already. Nothing here is stored: these rows are candidates until somebody ticks
|
||||||
|
/// them and presses the button.
|
||||||
|
/// </remarks>
|
||||||
|
private void Fill(HetznerImport import)
|
||||||
|
{
|
||||||
|
foreach (var server in import.Servers)
|
||||||
|
{
|
||||||
|
var row = new HetznerRowViewModel(server);
|
||||||
|
|
||||||
|
// The footer counts ticks, so it has to hear about one. Subscribed here rather than bound from
|
||||||
|
// the item template, because a template binding cannot reach the list it is inside without a
|
||||||
|
// parent binding — and the count is on the parent by definition.
|
||||||
|
row.PropertyChanged += OnRowChanged;
|
||||||
|
|
||||||
|
Rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var warning in import.Warnings)
|
||||||
|
{
|
||||||
|
Warnings.Add(warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
Refresh(resetSelection: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refills the vault and key pickers from the keychain, keeping whatever was already chosen.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Run at the start of every scan rather than once in a constructor, because the wizard outlives a
|
||||||
|
/// sync: a vault shared with this account, or a key a colleague pushed, between opening this screen and
|
||||||
|
/// pressing SCAN is a real answer that a list built at construction time would not be offering.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Selections are carried across <em>by id</em>, which is the same rule the host list's own reload
|
||||||
|
/// follows: the row objects are all new, so keeping the old ones would leave a picker showing a vault
|
||||||
|
/// the import would not actually write to. Anything that has since gone falls back to the default —
|
||||||
|
/// the active vault, and no key.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void RefreshChoices()
|
||||||
|
{
|
||||||
|
var vaultId = SelectedVault?.VaultId ?? vault.TargetVaultId;
|
||||||
|
var keyId = SelectedKey?.KeyId;
|
||||||
|
|
||||||
|
VaultChoices.Clear();
|
||||||
|
|
||||||
|
foreach (var choice in vault.TargetVaults)
|
||||||
|
{
|
||||||
|
VaultChoices.Add(choice);
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedVault = VaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId)
|
||||||
|
?? VaultChoices.FirstOrDefault();
|
||||||
|
|
||||||
|
KeyChoices.Clear();
|
||||||
|
KeyChoices.Add(HetznerKeyChoice.None);
|
||||||
|
|
||||||
|
foreach (var key in vault.Keys)
|
||||||
|
{
|
||||||
|
// The vault name where there is more than one to be in, exactly as the key list itself prints
|
||||||
|
// it: two vaults may each hold a "deploy", and a picker that binds forty machines to one of them
|
||||||
|
// has to say which. HasVaultBadge is that same test, already answered.
|
||||||
|
KeyChoices.Add(new HetznerKeyChoice(
|
||||||
|
key.EntityId,
|
||||||
|
key.VaultId,
|
||||||
|
key.HasVaultBadge ? $"{key.Label} · {key.VaultName}" : key.Label));
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedKey = KeyChoices.FirstOrDefault(choice => choice.KeyId == keyId) ?? HetznerKeyChoice.None;
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(HasVaultChoice));
|
||||||
|
OnPropertyChanged(nameof(HasKeyChoice));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops the list, unsubscribing from what it held.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The detach matters: rows outlive the list only through this handler, and a scan run four times would
|
||||||
|
/// otherwise leave three sets of dead rows still calling into a live view model on every tick.
|
||||||
|
/// </remarks>
|
||||||
|
private void ClearRows()
|
||||||
|
{
|
||||||
|
foreach (var row in Rows)
|
||||||
|
{
|
||||||
|
row.PropertyChanged -= OnRowChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rows.Clear();
|
||||||
|
Warnings.Clear();
|
||||||
|
RaiseListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Only the tick moves the footer. Every other property on a row changes as part of a refresh this type
|
||||||
|
/// started, which already ends in <see cref="RaiseListState"/>.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnRowChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
|
||||||
|
{
|
||||||
|
if (string.Equals(args.PropertyName, nameof(HetznerRowViewModel.IsSelected), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
RaiseListState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-resolves every row against the current address choice.</summary>
|
||||||
|
private void Refresh(bool resetSelection)
|
||||||
|
{
|
||||||
|
foreach (var row in Rows)
|
||||||
|
{
|
||||||
|
row.Refresh(AddressKind, IsAlreadyPresent, resetSelection);
|
||||||
|
}
|
||||||
|
|
||||||
|
RaiseListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the keychain already holds a host that would dial where this one does.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Matched on address, port and user rather than on name, for the reason
|
||||||
|
/// <see cref="ImportViewModel.IsAlreadyPresent"/> gives at more length: a machine bookmarked under
|
||||||
|
/// another name is the same machine, and matching on the label would offer a duplicate of something
|
||||||
|
/// already stored. Compared against the <em>resolved</em> host so that a stored host inheriting its port
|
||||||
|
/// or user from a group still matches.
|
||||||
|
/// </remarks>
|
||||||
|
private bool IsAlreadyPresent(HetznerAddress address) => vault.Hosts.Any(existing =>
|
||||||
|
string.Equals(existing.Host.Hostname, address.Value, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& existing.Resolved.Port.Value == Port
|
||||||
|
&& string.Equals(
|
||||||
|
existing.Resolved.Username.Value,
|
||||||
|
string.IsNullOrWhiteSpace(Username) ? null : Username.Trim(),
|
||||||
|
StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
partial void OnStepChanged(HetznerImportStep value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsTokenStep));
|
||||||
|
OnPropertyChanged(nameof(IsChooseStep));
|
||||||
|
OnPropertyChanged(nameof(IsDoneStep));
|
||||||
|
OnPropertyChanged(nameof(StepLabel));
|
||||||
|
OnPropertyChanged(nameof(StepTitle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The vault is named in three places on this step — the summary line, the note under it and the
|
||||||
|
/// sentence about a key left in another vault — and none of them is a control the picker owns.
|
||||||
|
/// </remarks>
|
||||||
|
partial void OnSelectedVaultChanged(VaultChoiceViewModel? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(VaultName));
|
||||||
|
OnPropertyChanged(nameof(SelectionSummary));
|
||||||
|
OnPropertyChanged(nameof(AuthenticationNote));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="OnSelectedVaultChanged" />
|
||||||
|
partial void OnSelectedKeyChanged(HetznerKeyChoice? value) =>
|
||||||
|
OnPropertyChanged(nameof(AuthenticationNote));
|
||||||
|
|
||||||
|
partial void OnAddressKindChanged(HetznerAddressKind value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(UsesPublicIPv4));
|
||||||
|
OnPropertyChanged(nameof(UsesPublicIPv6));
|
||||||
|
OnPropertyChanged(nameof(UsesPrivate));
|
||||||
|
OnPropertyChanged(nameof(AddressNote));
|
||||||
|
|
||||||
|
Refresh(resetSelection: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The user and the port are half of what "already here" is decided on, so changing either has to
|
||||||
|
/// re-ask the question — otherwise a row would go on claiming the keychain has it while the thing it
|
||||||
|
/// would now store is different.
|
||||||
|
/// </remarks>
|
||||||
|
partial void OnUsernameChanged(string value) => Refresh(resetSelection: false);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="OnUsernameChanged" />
|
||||||
|
partial void OnPortTextChanged(string value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsPortValid));
|
||||||
|
|
||||||
|
Refresh(resetSelection: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(CanImport));
|
||||||
|
|
||||||
|
private void RaiseListState()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasRows));
|
||||||
|
OnPropertyChanged(nameof(HasWarnings));
|
||||||
|
OnPropertyChanged(nameof(SelectedCount));
|
||||||
|
OnPropertyChanged(nameof(ImportableCount));
|
||||||
|
OnPropertyChanged(nameof(ImportLabel));
|
||||||
|
OnPropertyChanged(nameof(AllTicked));
|
||||||
|
OnPropertyChanged(nameof(CanImport));
|
||||||
|
OnPropertyChanged(nameof(SelectionSummary));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -371,7 +371,7 @@ internal sealed partial class ImportViewModel(
|
|||||||
|
|
||||||
var requests = chosen.Select(BuildRequest).ToList();
|
var requests = chosen.Select(BuildRequest).ToList();
|
||||||
|
|
||||||
var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true);
|
var outcome = await vault.ImportHostsAsync(requests, vaultId: null, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
|
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
|
||||||
// pressing the button twice harmless and visible rather than harmless and confusing.
|
// pressing the button twice harmless and visible rather than harmless and confusing.
|
||||||
|
|||||||
@@ -182,6 +182,17 @@ internal enum ShellScreen
|
|||||||
/// <see cref="TransfersViewModel.Remote"/>; see <see cref="OnScreenChanged"/>.
|
/// <see cref="TransfersViewModel.Remote"/>; see <see cref="OnScreenChanged"/>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
Buckets = 10,
|
Buckets = 10,
|
||||||
|
|
||||||
|
/// <summary>Importing hosts from a Hetzner Cloud project.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Reached from preferences and not from the nav rail, exactly as <see cref="Import"/> is, and for the
|
||||||
|
/// same reason: it is a task done once rather than a place to be. A member of its own rather than a mode
|
||||||
|
/// of <see cref="Import"/>, because the two read different things through different code and draw
|
||||||
|
/// different screens — a single "import" destination that remembered which kind you last opened would be
|
||||||
|
/// a destination whose meaning depended on history, which is the argument <see cref="Buckets"/> already
|
||||||
|
/// makes for not sharing a member with <see cref="Transfers"/>.
|
||||||
|
/// </remarks>
|
||||||
|
HetznerImport = 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -328,6 +339,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private readonly Func<string, Task>? copyToClipboard;
|
private readonly Func<string, Task>? copyToClipboard;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other direction, and it exists for one caller: a right-click in a terminal with nothing selected.
|
||||||
|
/// The renderer cannot read a clipboard — <see cref="TerminalClientOpcode.ClipboardPaste"/> says why —
|
||||||
|
/// so the gesture crosses the socket and is answered here. Null on a head that wires none, which is now
|
||||||
|
/// no head at all: it stays nullable because this class has to be constructible without a visual, which
|
||||||
|
/// is what every test that drives it does.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly Func<Task<string?>>? readClipboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What explains a connection that would not reach its host, handed to every <see cref="VaultViewModel"/>
|
||||||
|
/// this shell builds.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Held rather than passed straight through, because the vault is not built here: it is built on unlock,
|
||||||
|
/// which happens once per session and possibly more than once. Null means the standard probe, which is
|
||||||
|
/// what both heads use — a test passes one to keep a failed connection from putting a real DNS lookup and
|
||||||
|
/// a real socket in the middle of a unit suite.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly ISshReachabilityProbe? reachability;
|
||||||
|
|
||||||
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
||||||
private readonly Action<Action> post;
|
private readonly Action<Action> post;
|
||||||
|
|
||||||
@@ -493,6 +525,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
|
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
|
||||||
/// the Android head's distribution separately, so it must never acquire one by accident.
|
/// the Android head's distribution separately, so it must never acquire one by accident.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="readClipboard">
|
||||||
|
/// Reads this machine's clipboard, or null on a head that cannot. Answers the terminal's right-click
|
||||||
|
/// paste and nothing else — see the field it is kept in.
|
||||||
|
/// </param>
|
||||||
/// <param name="openInEditor">
|
/// <param name="openInEditor">
|
||||||
/// Opens a local path with whatever this machine opens that kind of file with, or null on a head that
|
/// Opens a local path with whatever this machine opens that kind of file with, or null on a head that
|
||||||
/// has no such notion. Forwarded straight to <see cref="TransfersViewModel"/>, which is the only thing
|
/// has no such notion. Forwarded straight to <see cref="TransfersViewModel"/>, which is the only thing
|
||||||
@@ -527,12 +563,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
Argon2Profile? passphraseProfile = null,
|
Argon2Profile? passphraseProfile = null,
|
||||||
ResumeHandler? resume = null,
|
ResumeHandler? resume = null,
|
||||||
Func<string, Task>? copyToClipboard = null,
|
Func<string, Task>? copyToClipboard = null,
|
||||||
|
Func<Task<string?>>? readClipboard = null,
|
||||||
string? deviceName = null,
|
string? deviceName = null,
|
||||||
IUpdateChannel? updates = null,
|
IUpdateChannel? updates = null,
|
||||||
Action<Action>? post = null,
|
Action<Action>? post = null,
|
||||||
Func<string, Task>? openInEditor = null,
|
Func<string, Task>? openInEditor = null,
|
||||||
Func<string, Action, IDisposable>? watchFile = null)
|
Func<string, Action, IDisposable>? watchFile = null,
|
||||||
|
ISshReachabilityProbe? reachability = null)
|
||||||
{
|
{
|
||||||
|
this.reachability = reachability;
|
||||||
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
||||||
|
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
@@ -545,6 +584,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
this.passphraseProfile = passphraseProfile;
|
this.passphraseProfile = passphraseProfile;
|
||||||
this.copyToClipboard = copyToClipboard;
|
this.copyToClipboard = copyToClipboard;
|
||||||
|
this.readClipboard = readClipboard;
|
||||||
|
|
||||||
// Whitespace is treated as absent rather than honoured: an empty device name reaches the server as a
|
// Whitespace is treated as absent rather than honoured: an empty device name reaches the server as a
|
||||||
// blank one, which RegisterDevice rejects, and a phone whose model string came back empty would fail
|
// blank one, which RegisterDevice rejects, and a phone whose model string came back empty would fail
|
||||||
@@ -575,6 +615,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||||
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
|
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
|
||||||
|
this.workspace.ClipboardCopyRequested += OnClipboardCopyRequested;
|
||||||
|
this.workspace.ClipboardPasteRequested += OnClipboardPasteRequested;
|
||||||
this.workspace.RendererReattached += OnRendererReattached;
|
this.workspace.RendererReattached += OnRendererReattached;
|
||||||
|
|
||||||
settings = new ClientSettingsStore(paths);
|
settings = new ClientSettingsStore(paths);
|
||||||
@@ -726,6 +768,106 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
|
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
|
||||||
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
|
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A right-click over a selection: the page has sent the text, and this puts it on the clipboard.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Marshalled for the reason <see cref="OnFontSizeStepRequested"/> is — this arrives on the data plane's
|
||||||
|
/// receive loop — plus one of its own, which is the stronger: an Avalonia clipboard is reached through a
|
||||||
|
/// <c>TopLevel</c>, and a <c>TopLevel</c> is only safe to touch from the thread that owns the window.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Through <see cref="post"/> rather than <c>Dispatcher.UIThread</c> directly, unlike the handlers
|
||||||
|
/// beside it: those predate the delegate and are the ones this shell's suite records as out of its
|
||||||
|
/// reach. There is no reason to add another.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnClipboardCopyRequested(object? sender, TerminalClipboardCopyEventArgs e) =>
|
||||||
|
post(() => _ = CopyFromTerminalAsync(e.Text));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A right-click with nothing selected: reads the clipboard and hands it to the pane that asked.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Marshalled for the reasons <see cref="OnClipboardCopyRequested"/> gives.</remarks>
|
||||||
|
private void OnClipboardPasteRequested(object? sender, TerminalClipboardPasteEventArgs e) =>
|
||||||
|
post(() => _ = PasteIntoTerminalAsync(e.SessionId));
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Silence where there is no clipboard, and that is a different answer from the keychain's COPY buttons
|
||||||
|
/// — which say so, because a button that appears to do nothing is worse than a refusal. There is nowhere
|
||||||
|
/// to say it from here: the shell's status line is drawn on the enroll, sign-in and unlock cards, none
|
||||||
|
/// of which is on screen while somebody is right-clicking a terminal. Inventing a channel for a branch
|
||||||
|
/// no shipped head reaches would be the wrong trade, and both heads wire a clipboard.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A throw is swallowed rather than left to become an unobserved task exception. A clipboard is a shared
|
||||||
|
/// machine resource — on Windows another process holding it open is an ordinary, transient failure — and
|
||||||
|
/// a copy that did not happen is worth exactly one more right-click.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task CopyFromTerminalAsync(string text)
|
||||||
|
{
|
||||||
|
if (copyToClipboard is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await copyToClipboard(text).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Deliberately silent; see the remark for both halves of why. Nothing is left half done — the
|
||||||
|
// clipboard either took the text or did not.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// An empty clipboard ends here rather than at the renderer. Sending it would bracket nothing and hand
|
||||||
|
/// the remote an empty paste, which is a keystroke nobody made; "there was nothing to paste" is a fact
|
||||||
|
/// about the machine rather than about this terminal, and the honest answer to it is that the
|
||||||
|
/// right-click did nothing — which is what every other terminal does with an empty clipboard too.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The answer for a session whose shell has ended is discarded for the reason
|
||||||
|
/// <see cref="TerminalWorkspace.SendClipboardAsync"/> gives for refusing it: nothing visible would
|
||||||
|
/// happen either way, and there is nowhere on this screen to say so.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A throw is swallowed, as in <see cref="CopyFromTerminalAsync"/> and for the same reason.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task PasteIntoTerminalAsync(uint sessionId)
|
||||||
|
{
|
||||||
|
if (readClipboard is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var text = await readClipboard().ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await workspace
|
||||||
|
.SendClipboardAsync(sessionId, text, CancellationToken.None)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Deliberately silent; see the remark. The read and the send are both inside it, because
|
||||||
|
// whichever of the two failed leaves the same nothing on screen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
|
/// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
|
||||||
@@ -913,6 +1055,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ImportViewModel? importScreen;
|
private ImportViewModel? importScreen;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="KnownHostsScreen" />
|
||||||
|
[ObservableProperty]
|
||||||
|
private HetznerImportViewModel? hetznerImportScreen;
|
||||||
|
|
||||||
/// <inheritdoc cref="KnownHostsScreen" />
|
/// <inheritdoc cref="KnownHostsScreen" />
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private SnippetsViewModel? snippetsScreen;
|
private SnippetsViewModel? snippetsScreen;
|
||||||
@@ -1414,6 +1560,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (target is ShellScreen.HetznerImport)
|
||||||
|
{
|
||||||
|
OpenHetznerImport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Any other screen leaves settings mode outright rather than restoring whatever was remembered on
|
// Any other screen leaves settings mode outright rather than restoring whatever was remembered on
|
||||||
// the way in — the caller named a destination, and that destination wins over "go back".
|
// the way in — the caller named a destination, and that destination wins over "go back".
|
||||||
ActiveSettingsPage = null;
|
ActiveSettingsPage = null;
|
||||||
@@ -1476,10 +1628,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
private bool isImportOpen;
|
private bool isImportOpen;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the Preferences page itself, rather than the importer drawn over it, is what settings mode's
|
/// Whether the Hetzner wizard, rather than the ssh_config importer, is the one drawn over Preferences.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A second flag rather than a second value of one, because they are two independent facts about the
|
||||||
|
/// content column and a single "which importer" enum would have to carry a "neither" member that
|
||||||
|
/// <see cref="IsImportOpen"/> already expresses. What keeps them from both being true is that every
|
||||||
|
/// path that opens one closes the other — see <see cref="OpenImport"/> and
|
||||||
|
/// <see cref="OpenHetznerImport"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isHetznerImportOpen;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether either importer is up, which is the one thing the titlebar's back button cares about.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both back out to the same place by the same command, so the bar asks this rather than asking twice.
|
||||||
|
/// See <c>SettingsTitleBar.axaml</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool IsImporterOpen => IsImportOpen || IsHetznerImportOpen;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the Preferences page itself, rather than an importer drawn over it, is what settings mode's
|
||||||
/// content column shows.
|
/// content column shows.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImportOpen;
|
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImporterOpen;
|
||||||
|
|
||||||
partial void OnActiveSettingsPageChanged(SettingsPage? value)
|
partial void OnActiveSettingsPageChanged(SettingsPage? value)
|
||||||
{
|
{
|
||||||
@@ -1494,8 +1668,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
|
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnIsImportOpenChanged(bool value) =>
|
partial void OnIsImportOpenChanged(bool value) => RaiseImporterState();
|
||||||
|
|
||||||
|
/// <inheritdoc cref="OnIsImportOpenChanged" />
|
||||||
|
partial void OnIsHetznerImportOpenChanged(bool value) => RaiseImporterState();
|
||||||
|
|
||||||
|
private void RaiseImporterState()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsImporterOpen));
|
||||||
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
|
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary>
|
/// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1518,6 +1700,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
ActiveSettingsPage = page;
|
ActiveSettingsPage = page;
|
||||||
IsImportOpen = false;
|
IsImportOpen = false;
|
||||||
|
IsHetznerImportOpen = false;
|
||||||
|
|
||||||
Screen = page switch
|
Screen = page switch
|
||||||
{
|
{
|
||||||
@@ -1539,9 +1722,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
IsImportOpen = true;
|
IsImportOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>"Back to preferences": closes the importer without leaving settings mode.</summary>
|
/// <summary>
|
||||||
|
/// Opens the Hetzner wizard over the Preferences page — the Preferences row's own button, and
|
||||||
|
/// <see cref="ShowScreen"/>'s translation of <see cref="ShellScreen.HetznerImport"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Reopened from the first step each time rather than resumed where it was left, and that is a decision
|
||||||
|
/// rather than an omission: the wizard's first step holds a credential it deliberately does not keep, so
|
||||||
|
/// a "resumed" second step would be a list nothing on screen could refresh. See
|
||||||
|
/// <see cref="HetznerImportViewModel"/>.
|
||||||
|
/// </remarks>
|
||||||
|
private void OpenHetznerImport()
|
||||||
|
{
|
||||||
|
EnterSettings(SettingsPage.Preferences);
|
||||||
|
|
||||||
|
HetznerImportScreen?.RestartCommand.Execute(null);
|
||||||
|
|
||||||
|
IsHetznerImportOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>"Back to preferences": closes whichever importer is up, without leaving settings mode.</summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CloseImport() => IsImportOpen = false;
|
private void CloseImport()
|
||||||
|
{
|
||||||
|
IsImportOpen = false;
|
||||||
|
IsHetznerImportOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>"Back to application": leaves settings mode for wherever it was entered from.</summary>
|
/// <summary>"Back to application": leaves settings mode for wherever it was entered from.</summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1554,6 +1760,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
ActiveSettingsPage = null;
|
ActiveSettingsPage = null;
|
||||||
IsImportOpen = false;
|
IsImportOpen = false;
|
||||||
|
IsHetznerImportOpen = false;
|
||||||
Screen = settingsReturnScreen;
|
Screen = settingsReturnScreen;
|
||||||
Surface = settingsReturnSurface;
|
Surface = settingsReturnSurface;
|
||||||
}
|
}
|
||||||
@@ -2678,7 +2885,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
ReconnectAsync,
|
ReconnectAsync,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
connectionLog,
|
connectionLog,
|
||||||
visibility);
|
visibility,
|
||||||
|
reachability);
|
||||||
State = ShellState.Unlocked;
|
State = ShellState.Unlocked;
|
||||||
|
|
||||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||||
@@ -3203,6 +3411,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||||
|
workspace.ClipboardCopyRequested -= OnClipboardCopyRequested;
|
||||||
|
workspace.ClipboardPasteRequested -= OnClipboardPasteRequested;
|
||||||
workspace.RendererReattached -= OnRendererReattached;
|
workspace.RendererReattached -= OnRendererReattached;
|
||||||
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
||||||
|
|
||||||
@@ -3434,6 +3644,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
? null
|
? null
|
||||||
: new ImportViewModel(newValue, new SshConfigLocator(), CloseImport);
|
: new ImportViewModel(newValue, new SshConfigLocator(), CloseImport);
|
||||||
|
|
||||||
|
// Its own transport, built once with the vault rather than per scan: an HttpClient is meant to be
|
||||||
|
// long-lived, and this one goes exactly one place — see HetznerCloudClient.CloudApi.
|
||||||
|
HetznerImportScreen = newValue is null
|
||||||
|
? null
|
||||||
|
: new HetznerImportViewModel(newValue, new HetznerCloudClient(), CloseImport);
|
||||||
|
|
||||||
SnippetsScreen?.Detach();
|
SnippetsScreen?.Detach();
|
||||||
SnippetsScreen = newValue is null
|
SnippetsScreen = newValue is null
|
||||||
? null
|
? null
|
||||||
@@ -3545,7 +3761,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
var tab = new TerminalTabViewModel(e.Label, e.Address) { HostId = e.HostId };
|
||||||
attempts[e.AttemptId] = tab;
|
attempts[e.AttemptId] = tab;
|
||||||
|
|
||||||
AdoptTab(tab);
|
AdoptTab(tab);
|
||||||
@@ -3609,6 +3825,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// dropping it would leave a shell running with nothing in the window naming it.
|
// dropping it would leave a shell running with nothing in the window naming it.
|
||||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||||
{
|
{
|
||||||
|
HostId = e.HostId,
|
||||||
StartedAt = clock.GetUtcNow(),
|
StartedAt = clock.GetUtcNow(),
|
||||||
Cipher = NullIfEmpty(e.Cipher),
|
Cipher = NullIfEmpty(e.Cipher),
|
||||||
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||||
@@ -3820,13 +4037,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The host this tab was opened against, or null when the keychain has nothing under that name.
|
/// The host this tab was opened against, or null when the keychain no longer holds it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Matched on the label, which is the same handle <see cref="RefreshConnectedHosts"/> matches on and for
|
/// ◆ <b>Matched on the keychain item's id, never on the name.</b> Nothing stops two hosts being called
|
||||||
/// the same reason: a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
/// the same thing — a team's vault beside a personal one makes it ordinary, and "db" is what people call
|
||||||
/// still mean anything after a lock.
|
/// their database server in both — and this is the lookup <see cref="ReconnectTabAsync"/> dials the
|
||||||
|
/// answer to. A name match would take a dropped session on one machine and quietly reopen a shell on
|
||||||
|
/// another, with the tab, the status bar and the address all still naming the first. The id is carried
|
||||||
|
/// from the attempt that opened the tab; see <see cref="TerminalTabViewModel.HostId"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It outlives a lock, which is what the name match was here to survive.</b> The id is the item's own
|
||||||
|
/// sync identity rather than a reference to a row object, so a vault that is locked and opened again
|
||||||
|
/// rebuilds its list around the same ids and a tab from before the lock finds its host in it. A host
|
||||||
|
/// renamed since the tab opened is found too.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
|
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
|
||||||
@@ -3835,10 +4061,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
|
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
|
||||||
tab is null || Vault is not { } vault
|
tab?.HostId is not { } hostId || Vault is not { } vault
|
||||||
? null
|
? null
|
||||||
: vault.Hosts.FirstOrDefault(
|
: vault.Hosts.FirstOrDefault(row => row.EntityId == hostId);
|
||||||
row => string.Equals(row.Label, tab.Label, StringComparison.Ordinal));
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the selected tab has stopped and there is something to dial again.
|
/// Whether the selected tab has stopped and there is something to dial again.
|
||||||
@@ -4166,10 +4391,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
|
/// Matched on the keychain item's id, the same handle <see cref="HostFor"/> matches on and for the
|
||||||
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
/// reason given there: two hosts may share a name, and this list is read as "which machines am I on".
|
||||||
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
|
/// Lighting both dots for a name two machines answer to would say the user has a shell open on a host
|
||||||
/// wrong than a dot that goes dark when the vault is reopened.
|
/// they have never connected to — and it is the same match that decides whose pinned paths the sidebar
|
||||||
|
/// offers, which would then be another host's folders.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A tab with no item behind it — a machine reached by typing an address — lights nothing, which is
|
||||||
|
/// correct: there is no row here that it is a session on.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
|
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
|
||||||
@@ -4192,14 +4422,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
foreach (var host in vault.Hosts)
|
foreach (var host in vault.Hosts)
|
||||||
{
|
{
|
||||||
host.IsConnected = Tabs.Any(
|
host.IsConnected = Tabs.Any(tab => tab.IsLive && tab.HostId == host.EntityId);
|
||||||
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
|
||||||
|
|
||||||
// The active tab's host, and only when it is actually connected: the sidebar is for a session
|
// The active tab's host, and only when it is actually connected: the sidebar is for a session
|
||||||
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
||||||
if (host.IsConnected
|
if (host.IsConnected && SelectedTab is { } selected && selected.HostId == host.EntityId)
|
||||||
&& SelectedTab is { } selected
|
|
||||||
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
connectedActiveHost = host;
|
connectedActiveHost = host;
|
||||||
}
|
}
|
||||||
@@ -4333,15 +4560,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
|
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
|
||||||
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
|
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
|
||||||
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
|
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
|
||||||
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
|
/// the same <see cref="HostFor"/> match <see cref="RefreshConnectedHosts"/> used to decide the row is
|
||||||
|
/// there to click — which is the point: the pins on screen and the host they are opened on come from one
|
||||||
|
/// answer to "which machine is this tab", and two hosts of the same name cannot pull them apart.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenPinnedPathAsync(string path)
|
private async Task OpenPinnedPathAsync(string path)
|
||||||
{
|
{
|
||||||
if (Vault is not { } vault
|
if (HostFor(SelectedTab) is not { } host)
|
||||||
|| SelectedTab is not { } tab
|
|
||||||
|| vault.Hosts.FirstOrDefault(
|
|
||||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4422,10 +4648,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void PinFolderFromSidebar()
|
private void PinFolderFromSidebar()
|
||||||
{
|
{
|
||||||
if (Vault is not { } vault
|
if (Vault is not { } vault || HostFor(SelectedTab) is not { } host)
|
||||||
|| SelectedTab is not { } tab
|
|
||||||
|| vault.Hosts.FirstOrDefault(
|
|
||||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4458,10 +4681,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
|
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
|
||||||
{
|
{
|
||||||
if (Vault is not { } vault
|
if (HostFor(tab) is not { } host)
|
||||||
|| tab is null
|
|
||||||
|| vault.Hosts.FirstOrDefault(
|
|
||||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
|
|
||||||
internal string Label { get; }
|
internal string Label { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keychain item this tab was opened from, or null for a machine reached by typing an address.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>What the shell matches this tab back to a host row with, because the name will not do it.</b>
|
||||||
|
/// Nothing stops two hosts being called the same thing, and a team's vault beside a personal one makes it
|
||||||
|
/// ordinary — so a lookup on <see cref="Label"/> can answer with a different machine that happens to
|
||||||
|
/// share the name. The reconnect under a stopped tab <em>dials</em> whatever that lookup finds, which is
|
||||||
|
/// the one place being approximately right is dialling a stranger. See
|
||||||
|
/// <c>MainWindowViewModel.HostFor</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It survives a lock, which the row objects do not.</b> This is the item's sync identity rather than
|
||||||
|
/// an index into the list on screen — see <c>VaultItem<TSecret>.EntityId</c> — so a vault that is
|
||||||
|
/// locked and opened again rebuilds every row around the same ids, and a tab that outlived the vault
|
||||||
|
/// finds its host again. A rename finds it too, which a name match by definition cannot.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Null is not missing data: it is the whole of what makes a typed-in address different, and it is why
|
||||||
|
/// such a tab is honestly refused a reconnect rather than offered one that would dial a namesake.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal Guid? HostId { get; init; }
|
||||||
|
|
||||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||||
internal string Address { get; }
|
internal string Address { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
|
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
|
||||||
|
|
||||||
|
/// <summary>How long the look taken on the way to a restart may take before it is given up on.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Ten seconds. It bounds one HTTPS request for a small JSON document, which ordinarily answers in well
|
||||||
|
/// under one — and the case it exists for is not a slow answer but no answer at all: a captive portal or
|
||||||
|
/// a firewall that drops packets rather than refusing them, where the underlying handler's own timeout is
|
||||||
|
/// a hundred seconds. A refused connection fails in milliseconds and never reaches this.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Bounded because somebody is waiting on it with a finger on RESTART NOW, which is the whole difference
|
||||||
|
/// from the timer's own pass. Giving up is not a failure here: the build already on disk is still newer
|
||||||
|
/// than the one running, so the restart goes ahead with it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly TimeSpan LastLookTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
private readonly IUpdateChannel updates;
|
private readonly IUpdateChannel updates;
|
||||||
private readonly ClientSettingsStore settings;
|
private readonly ClientSettingsStore settings;
|
||||||
private readonly TimeProvider clock;
|
private readonly TimeProvider clock;
|
||||||
@@ -143,6 +159,18 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private DateTimeOffset? lastChecked;
|
private DateTimeOffset? lastChecked;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a restart is under way, including the look it takes before replacing anything.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// It exists to keep the banner up. Everything the banner shows keys off <see cref="IsReady"/>, and the
|
||||||
|
/// last look moves the state to Checking and possibly Downloading — so without this, pressing RESTART NOW
|
||||||
|
/// makes the strip vanish and leaves nothing on screen saying why, for however long a package takes. See
|
||||||
|
/// <see cref="ReadyHeadline"/>, which is what it says instead.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isRestarting;
|
||||||
|
|
||||||
internal bool IsSupported => State is not UpdateState.Unsupported;
|
internal bool IsSupported => State is not UpdateState.Unsupported;
|
||||||
|
|
||||||
internal bool IsUnsupported => State is UpdateState.Unsupported;
|
internal bool IsUnsupported => State is UpdateState.Unsupported;
|
||||||
@@ -155,12 +183,37 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
|
|
||||||
internal bool CanCheckNow => IsSupported && State is not (UpdateState.Checking or UpdateState.Downloading);
|
internal bool CanCheckNow => IsSupported && State is not (UpdateState.Checking or UpdateState.Downloading);
|
||||||
|
|
||||||
internal bool IsBannerShowing => IsReady && !IsBannerDismissed;
|
internal bool IsBannerShowing => (IsReady || IsRestarting) && !IsBannerDismissed;
|
||||||
|
|
||||||
/// <summary>What the banner says.</summary>
|
/// <summary>What the banner says.</summary>
|
||||||
internal string ReadyHeadline => ReadyVersion is { Length: > 0 } version
|
/// <remarks>
|
||||||
? $"DodoSSH {version} is ready to install."
|
/// Three sentences rather than one, because a restart no longer necessarily starts with a build in hand:
|
||||||
: "An update is ready to install.";
|
/// it takes one last look first, and may find something newer than what was fetched six hours ago. That
|
||||||
|
/// look is short and the fetch behind it is not, so both say what they are doing — a strip that read
|
||||||
|
/// "ready to install" while it downloaded would be describing the wrong build, and a blank one would be
|
||||||
|
/// describing nothing.
|
||||||
|
/// </remarks>
|
||||||
|
internal string ReadyHeadline
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (IsRestarting && State is UpdateState.Checking)
|
||||||
|
{
|
||||||
|
return "Checking for a newer build before restarting…";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsRestarting && State is UpdateState.Downloading)
|
||||||
|
{
|
||||||
|
return string.Create(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
$"A newer build appeared. Fetching it before restarting — {DownloadPercent}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReadyVersion is { Length: > 0 } version
|
||||||
|
? $"DodoSSH {version} is ready to install."
|
||||||
|
: "An update is ready to install.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>What restarting costs, in the terms this application has already taught.</summary>
|
/// <summary>What restarting costs, in the terms this application has already taught.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -201,10 +254,24 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
OnPropertyChanged(nameof(CanCheckNow));
|
OnPropertyChanged(nameof(CanCheckNow));
|
||||||
OnPropertyChanged(nameof(IsBannerShowing));
|
OnPropertyChanged(nameof(IsBannerShowing));
|
||||||
OnPropertyChanged(nameof(RestartWarning));
|
OnPropertyChanged(nameof(RestartWarning));
|
||||||
|
OnPropertyChanged(nameof(ReadyHeadline));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnIsBannerDismissedChanged(bool value) => OnPropertyChanged(nameof(IsBannerShowing));
|
partial void OnIsBannerDismissedChanged(bool value) => OnPropertyChanged(nameof(IsBannerShowing));
|
||||||
|
|
||||||
|
partial void OnIsRestartingChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsBannerShowing));
|
||||||
|
OnPropertyChanged(nameof(ReadyHeadline));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The headline carries the percentage while a restart is fetching, so it has to be re-read as the bar
|
||||||
|
/// moves. Raised unconditionally rather than only while restarting, because a condition here would be a
|
||||||
|
/// second place that has to agree with the headline's own about when the percentage is in it.
|
||||||
|
/// </remarks>
|
||||||
|
partial void OnDownloadPercentChanged(int value) => OnPropertyChanged(nameof(ReadyHeadline));
|
||||||
|
|
||||||
partial void OnReadyVersionChanged(string? value) => OnPropertyChanged(nameof(ReadyHeadline));
|
partial void OnReadyVersionChanged(string? value) => OnPropertyChanged(nameof(ReadyHeadline));
|
||||||
|
|
||||||
partial void OnLastCheckedChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastCheckedSummary));
|
partial void OnLastCheckedChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastCheckedSummary));
|
||||||
@@ -278,14 +345,32 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
|
|
||||||
/// <summary>One pass of the loop.</summary>
|
/// <summary>One pass of the loop.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// <b>Quiet by construction.</b> A pass that finds nothing writes nothing, and a pass that cannot reach
|
/// <b>Quiet by construction.</b> A pass that finds nothing writes nothing, and a pass that cannot reach
|
||||||
/// the forge writes nothing either — an unreachable release page is a laptop on a train, it is not news,
|
/// the forge writes nothing either — an unreachable release page is a laptop on a train, it is not news,
|
||||||
/// and it heals itself in six hours. The same discipline as <c>VaultViewModel.AutoSyncAsync</c>: what
|
/// and it heals itself in six hours. The same discipline as <c>VaultViewModel.AutoSyncAsync</c>: what
|
||||||
/// nobody asked for may only speak when it has something to say.
|
/// nobody asked for may only speak when it has something to say.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>A pass still runs when one is already fetched, and this used to be the one state that stopped it
|
||||||
|
/// for good.</b> The guard read <c>Downloading or Ready</c>, on the reasonable-looking argument that
|
||||||
|
/// there is no point fetching what has already been fetched. What it actually did was stop <em>asking</em>
|
||||||
|
/// — so the moment anything was downloaded, the banner was pinned to that build for the rest of the
|
||||||
|
/// process's life, however many newer ones were published afterwards. On the nightly channel, where the
|
||||||
|
/// release is deleted and remade on every push to main, that is most of the time: the client offered the
|
||||||
|
/// build it happened to see first, installed it, restarted, and immediately offered the next one. Two
|
||||||
|
/// updates and two restarts to move one step.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Not fetching what is already fetched is still the rule, and <see cref="FetchAsync"/> is where it now
|
||||||
|
/// lives — by comparing versions rather than by refusing to look. Downloading is still excluded, because
|
||||||
|
/// a fetch in flight must not be restarted underneath itself; Checking is excluded for the same reason,
|
||||||
|
/// and it is a pressed CHECK NOW that a tick would otherwise land on top of.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal async Task CheckOnceAsync(CancellationToken cancellationToken)
|
internal async Task CheckOnceAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
|
if (!IsAutomatic || State is UpdateState.Checking or UpdateState.Downloading)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -339,27 +424,91 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Checks, and fetches whatever it finds.</summary>
|
/// <summary>Checks, and fetches whatever it finds that is not already fetched.</summary>
|
||||||
/// <returns>The update found, or null.</returns>
|
/// <param name="cancellationToken">Cancellation.</param>
|
||||||
private async Task<AvailableUpdate?> FetchAsync(CancellationToken cancellationToken)
|
/// <param name="checkWithin">
|
||||||
|
/// A bound on the check alone, or null for none. See <see cref="LastLookTimeout"/>. Deliberately not a
|
||||||
|
/// bound on the whole method: a package legitimately takes minutes, and a caller that wants a short
|
||||||
|
/// answer wants it about the <em>question</em>, not about the download the answer may lead to.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>What is waiting to be installed once this has run, or null if nothing is.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>It answers with what is pending rather than with what the check returned, and the difference is
|
||||||
|
/// what makes a second pass safe.</b> A client running 1.0 that has fetched 1.1 is told 1.1 by every
|
||||||
|
/// later check, because 1.1 is still the newest thing on the feed — so "the check found something" is
|
||||||
|
/// not the same question as "there is something new". Comparing the two is what lets the loop keep
|
||||||
|
/// asking without fetching the same package every six hours, and it is what replaces the pending build
|
||||||
|
/// when the feed has moved on underneath it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A build already fetched is never thrown away, including when a check answers null. Null means the
|
||||||
|
/// feed's newest is no newer than the build that is <em>running</em>, which says nothing about the one
|
||||||
|
/// on disk; that one is still newer than what is running, and withdrawing an offer somebody has already
|
||||||
|
/// been shown would be the worse answer.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<AvailableUpdate?> FetchAsync(
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
TimeSpan? checkWithin = null)
|
||||||
{
|
{
|
||||||
State = UpdateState.Checking;
|
State = UpdateState.Checking;
|
||||||
Status = string.Empty;
|
Status = string.Empty;
|
||||||
|
|
||||||
var found = await updates.CheckAsync(cancellationToken).ConfigureAwait(true);
|
var found = await AskAsync(checkWithin, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
LastChecked = clock.GetUtcNow();
|
LastChecked = clock.GetUtcNow();
|
||||||
|
|
||||||
if (found is null)
|
if (found is null)
|
||||||
{
|
{
|
||||||
State = UpdateState.Idle;
|
// Answered with what is still pending rather than with nothing, which is what keeps a pressed
|
||||||
|
// CHECK NOW honest: "you are on the latest build" is true of the build that is running and false
|
||||||
|
// of the machine, if there is one downloaded and waiting to be it.
|
||||||
|
State = ready is null ? UpdateState.Idle : UpdateState.Ready;
|
||||||
|
|
||||||
return null;
|
if (ready is { } waiting)
|
||||||
|
{
|
||||||
|
Status = Waiting(waiting);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ready is { } already
|
||||||
|
&& string.Equals(already.Version, found.Version, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
// The ordinary answer for every pass after the first one that found something. Not fetched
|
||||||
|
// again, which is the rule the old Ready guard was reaching for and got by not asking at all.
|
||||||
|
State = UpdateState.Ready;
|
||||||
|
Status = Waiting(already);
|
||||||
|
|
||||||
|
return already;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await FetchIntoReadyAsync(found, cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Downloads one update and offers it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Split from <see cref="FetchAsync"/> for length rather than for reuse — it has one caller — and the
|
||||||
|
/// division is a real one: above is which build to fetch, below is fetching it.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<AvailableUpdate> FetchIntoReadyAsync(
|
||||||
|
AvailableUpdate found,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var superseded = ready;
|
||||||
|
|
||||||
State = UpdateState.Downloading;
|
State = UpdateState.Downloading;
|
||||||
DownloadPercent = 0;
|
DownloadPercent = 0;
|
||||||
|
|
||||||
|
// Said while it downloads, and only when this displaces something already fetched. Both places that
|
||||||
|
// shows are places somebody is looking: a banner whose version changes under them, and a RESTART NOW
|
||||||
|
// that starts a download instead of a restart.
|
||||||
|
Status = superseded is { } stale
|
||||||
|
? $"DodoSSH {found.Version} appeared while {stale.Version} was waiting. Fetching it instead."
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
// Monotonic, and it has to be. Progress<T> delivers its callbacks by posting them to the captured
|
// Monotonic, and it has to be. Progress<T> delivers its callbacks by posting them to the captured
|
||||||
// synchronisation context rather than invoking them inline, so a report can arrive after the
|
// synchronisation context rather than invoking them inline, so a report can arrive after the
|
||||||
// download has already returned — and an unguarded assignment then puts a stale smaller number
|
// download has already returned — and an unguarded assignment then puts a stale smaller number
|
||||||
@@ -382,13 +531,43 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
DownloadPercent = 100;
|
DownloadPercent = 100;
|
||||||
ready = found;
|
ready = found;
|
||||||
ReadyVersion = found.Version;
|
ReadyVersion = found.Version;
|
||||||
|
|
||||||
|
// Undismissed, because this is a different build from the one that was dismissed. LATER was an
|
||||||
|
// answer about a version, and there is a new question.
|
||||||
IsBannerDismissed = false;
|
IsBannerDismissed = false;
|
||||||
State = UpdateState.Ready;
|
State = UpdateState.Ready;
|
||||||
Status = $"DodoSSH {found.Version} is downloaded and will run after a restart.";
|
Status = Waiting(found);
|
||||||
|
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks the channel, giving up after <paramref name="within"/> if one was given.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The wait is bounded rather than the request cancelled, and that is not the same thing: the desktop
|
||||||
|
/// channel's own remark records that Velopack's check takes no token, so cancelling would be observed
|
||||||
|
/// only once the call had already returned. Waiting on it with a timeout is what actually gives an
|
||||||
|
/// answer back in ten seconds.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Which leaves the request running, and it is the reason the channel had to learn to remember what it
|
||||||
|
/// <em>downloaded</em> rather than only what it last found: an orphaned check can land afterwards and
|
||||||
|
/// move the channel's idea of the newest build, and the restart that gave up on it is about to name the
|
||||||
|
/// older one. See <c>VelopackUpdateChannel.Matched</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<AvailableUpdate?> AskAsync(TimeSpan? within, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var asking = updates.CheckAsync(cancellationToken);
|
||||||
|
|
||||||
|
return within is { } bound
|
||||||
|
? await asking.WaitAsync(bound, clock, cancellationToken).ConfigureAwait(true)
|
||||||
|
: await asking.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Waiting(AvailableUpdate update) =>
|
||||||
|
$"DodoSSH {update.Version} is downloaded and will run after a restart.";
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Hands the update to whoever was given the job at composition rather than applying it here, and the
|
/// Hands the update to whoever was given the job at composition rather than applying it here, and the
|
||||||
@@ -406,26 +585,89 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task RestartNowAsync()
|
private async Task RestartNowAsync()
|
||||||
{
|
{
|
||||||
if (ready is not { } update)
|
if (ready is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ◆ Reported rather than allowed to escape, and the phone is why. Where applying ends the process
|
IsRestarting = true;
|
||||||
// a throw here is nearly unreachable; where it does not — see ApplyingEndsTheProcess — the whole
|
|
||||||
// point is that this returns and the application carries on, so an exception has somewhere to go:
|
|
||||||
// out of a command handler, past the dispatcher, and into the process. What that looks like to
|
|
||||||
// somebody holding the phone is pressing INSTALL and having the application close, with nothing
|
|
||||||
// installed and nothing said. Failed and the message is the same answer CheckNowAsync gives, and
|
|
||||||
// it is the difference between a bug that describes itself and one that presents as a crash.
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await restart(update).ConfigureAwait(true);
|
await TakeOneLastLookAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (ready is not { } update)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ◆ Reported rather than allowed to escape, and the phone is why. Where applying ends the
|
||||||
|
// process a throw here is nearly unreachable; where it does not — see ApplyingEndsTheProcess —
|
||||||
|
// the whole point is that this returns and the application carries on, so an exception has
|
||||||
|
// somewhere to go: out of a command handler, past the dispatcher, and into the process. What
|
||||||
|
// that looks like to somebody holding the phone is pressing INSTALL and having the application
|
||||||
|
// close, with nothing installed and nothing said. Failed and the message is the same answer
|
||||||
|
// CheckNowAsync gives, and it is the difference between a bug that describes itself and one
|
||||||
|
// that presents as a crash.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await restart(update).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
State = UpdateState.Failed;
|
||||||
|
Status = exception.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Never reached on a desktop, where restart() replaces the process from inside the try. It is
|
||||||
|
// for the phone, where INSTALL is a request the platform may refuse, and for every failure
|
||||||
|
// path: a flag that keeps the banner up has to come down again.
|
||||||
|
IsRestarting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks the feed once more, on the way to replacing this process.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Because what was fetched can be hours old, and a restart is the one moment being one build
|
||||||
|
/// behind is expensive.</b> The loop asks every six hours; a build published between the last pass and
|
||||||
|
/// this press would otherwise be installed on the <em>next</em> restart, which is to say: update,
|
||||||
|
/// restart, be told at once that another update is waiting, update, restart again. Two of everything to
|
||||||
|
/// move one step, which is exactly what a rolling nightly channel produces.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It costs one small request in the ordinary case, because the feed answers with the build already on
|
||||||
|
/// disk and <see cref="FetchAsync"/> compares rather than downloads. It costs a download only when there
|
||||||
|
/// genuinely is something newer, which is the case where the alternative was two downloads anyway.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing here refuses a restart.</b> A forge that cannot be reached, a look that ran out of time, a
|
||||||
|
/// fetch of the newer build that failed halfway — all of them leave the build already on disk untouched
|
||||||
|
/// and still newer than the one running, so the restart goes ahead with it. That is why this swallows
|
||||||
|
/// rather than reporting: <see cref="UpdateState.Failed"/> is for something a person is waiting on an
|
||||||
|
/// answer to, and this person is waiting on a restart they are about to get.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task TakeOneLastLookAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await FetchAsync(lifetime.Token, checkWithin: LastLookTimeout).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Shutting down, which is the one case that must not go on to restart: whatever is fetched is
|
||||||
|
// applied at the next ordinary launch anyway, and that is the policy rather than a fallback.
|
||||||
|
ready = null;
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
{
|
{
|
||||||
State = UpdateState.Failed;
|
// Including the bound running out. Back to the offer that was already on screen.
|
||||||
Status = exception.Message;
|
State = UpdateState.Ready;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count
|
|||||||
/// <summary>Whether this heading has a vault to name.</summary>
|
/// <summary>Whether this heading has a vault to name.</summary>
|
||||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What this heading is folded and unfolded by, in <c>VaultViewModel.collapsedGroups</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>The group's id for a group, and something else for "No group" — which is why this is not
|
||||||
|
/// <see cref="GroupId"/> with a fallback.</b> It was exactly that until the desktop's board grew a
|
||||||
|
/// heading per vault: there is now one "No group" heading <em>per vault</em>, and every one of them
|
||||||
|
/// answers <see cref="GroupId"/> with null. Keyed on <see cref="Guid.Empty"/> they were one heading as
|
||||||
|
/// far as the folded set was concerned, so folding one vault's unfiled hosts away folded every other
|
||||||
|
/// vault's with it. The desktop passes the vault's own id instead; the phone, whose list has a single
|
||||||
|
/// UNGROUPED heading spanning every vault, goes on passing <see cref="Guid.Empty"/>. Neither can collide
|
||||||
|
/// with a group id, and <see cref="Guid.Empty"/> is not a legal one — <c>HostSecret.TryValidate</c>
|
||||||
|
/// refuses it.
|
||||||
|
/// </remarks>
|
||||||
|
internal Guid CollapseKey { get; init; }
|
||||||
|
|
||||||
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
|
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
|
||||||
internal string Chevron => IsExpanded ? "▾" : "▸";
|
internal string Chevron => IsExpanded ? "▾" : "▸";
|
||||||
|
|
||||||
@@ -167,6 +183,31 @@ internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnly
|
|||||||
internal bool HasHeader => header is not null;
|
internal bool HasHeader => header is not null;
|
||||||
|
|
||||||
internal IReadOnlyList<HostRowViewModel> Hosts => hosts;
|
internal IReadOnlyList<HostRowViewModel> Hosts => hosts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The vault this section opens, or empty where it is not the first section of one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The board's outer level, and groups are the inner one.</b> A group belongs to exactly one vault
|
||||||
|
/// — it is an item stored under that vault's key, like the hosts filed into it — so a board that drew
|
||||||
|
/// every group in one run was flattening the containment that already exists. Two vaults each holding a
|
||||||
|
/// "production" made the point: they were two headings with the same word on them and a small grey
|
||||||
|
/// vault name beside each, distinguishable only by reading the second line. Vaults are now the headings
|
||||||
|
/// and groups sort the hosts inside one, which is the shape the data has.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Carried on the first section of each vault rather than as a section of its own, because a vault
|
||||||
|
/// heading is not a thing cards go under: every card is under a group heading, and the group headings
|
||||||
|
/// are what a vault contains. Empty on every other section, and empty on all of them where the board
|
||||||
|
/// draws one vault — a heading naming the only vault there is says nothing the screen does not already
|
||||||
|
/// say, and is the same test <see cref="HostRowViewModel.VaultBadge"/> has always applied to itself.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal string VaultHeading { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether this section opens a vault and should draw its name above the group heading.</summary>
|
||||||
|
internal bool StartsAVault => VaultHeading.Length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An entry in the toolbar's Group ▾ filter flyout.</summary>
|
/// <summary>An entry in the toolbar's Group ▾ filter flyout.</summary>
|
||||||
@@ -411,6 +452,19 @@ internal sealed partial class HostRowViewModel(
|
|||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
$"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");
|
$"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a card says when the pointer rests on it: the name in full, and the address under it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>The name is here because the card trims it and the tooltip was the one place it was not said.</b>
|
||||||
|
/// A tile is a fixed 214 wide (see <c>Border.tile</c> in App.axaml) and the name is drawn with
|
||||||
|
/// <c>TextTrimming</c>, so anything past about twenty characters ends in an ellipsis — which is the
|
||||||
|
/// honest thing for the card to do and leaves the machine unidentifiable on a board of hosts whose names
|
||||||
|
/// share a prefix. The tooltip carried the address alone, which named the *host* but not the bookmark:
|
||||||
|
/// six servers behind one address differ only by the half that was trimmed away.
|
||||||
|
/// </remarks>
|
||||||
|
internal string Tooltip => $"{Label}\n{Address}";
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// An em dash for "nobody", which covers both a host that states no username and a host whose group
|
/// An em dash for "nobody", which covers both a host that states no username and a host whose group
|
||||||
/// states none either. The two are the same thing to look at and the same thing at connect time: refused,
|
/// states none either. The two are the same thing to look at and the same thing at connect time: refused,
|
||||||
@@ -893,6 +947,7 @@ internal static class ItemBadge
|
|||||||
/// <param name="identityLabel">
|
/// <param name="identityLabel">
|
||||||
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="hostId">The keychain item this was dialled from, or null for a typed-in address.</param>
|
||||||
internal sealed class TerminalSessionEventArgs(
|
internal sealed class TerminalSessionEventArgs(
|
||||||
Guid attemptId,
|
Guid attemptId,
|
||||||
uint sessionId,
|
uint sessionId,
|
||||||
@@ -900,7 +955,8 @@ internal sealed class TerminalSessionEventArgs(
|
|||||||
string address,
|
string address,
|
||||||
string cipher,
|
string cipher,
|
||||||
string hostKeyAlgorithm,
|
string hostKeyAlgorithm,
|
||||||
string? identityLabel) : EventArgs
|
string? identityLabel,
|
||||||
|
Guid? hostId) : EventArgs
|
||||||
{
|
{
|
||||||
/// <summary>Which attempt this session came out of.</summary>
|
/// <summary>Which attempt this session came out of.</summary>
|
||||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
||||||
@@ -917,19 +973,27 @@ internal sealed class TerminalSessionEventArgs(
|
|||||||
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
||||||
|
|
||||||
internal string? IdentityLabel { get; } = identityLabel;
|
internal string? IdentityLabel { get; } = identityLabel;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ConnectionAttemptEventArgs.HostId" />
|
||||||
|
internal Guid? HostId { get; } = hostId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
||||||
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
|
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
|
||||||
/// <param name="label">The host's name, as the vault has it.</param>
|
/// <param name="label">The host's name, as the vault has it.</param>
|
||||||
/// <param name="address">Who this will be logged in as, and where.</param>
|
/// <param name="address">Who this will be logged in as, and where.</param>
|
||||||
|
/// <param name="hostId">The keychain item this is being dialled from, or null for a typed-in address.</param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
|
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
|
||||||
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
|
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
|
||||||
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
|
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
|
||||||
/// has no vault to read it from.
|
/// has no vault to read it from.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs
|
internal sealed class ConnectionAttemptEventArgs(
|
||||||
|
Guid attemptId,
|
||||||
|
string label,
|
||||||
|
string address,
|
||||||
|
Guid? hostId) : EventArgs
|
||||||
{
|
{
|
||||||
/// <summary>Identifies this attempt for the whole of its life.</summary>
|
/// <summary>Identifies this attempt for the whole of its life.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -941,6 +1005,19 @@ internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, s
|
|||||||
internal string Label { get; } = label;
|
internal string Label { get; } = label;
|
||||||
|
|
||||||
internal string Address { get; } = address;
|
internal string Address { get; } = address;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keychain item this connection was made from, or null for a machine that is not in the keychain.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>The name is not an identifier and this is.</b> Two hosts may be called the same thing — nothing
|
||||||
|
/// stops it, and a team vault next to a personal one makes it ordinary — so a tab that remembered only
|
||||||
|
/// what it was called could be matched back to the wrong machine. That is not a cosmetic mix-up: the
|
||||||
|
/// reconnect below the tab dials whichever row the match found, so the one thing that must not be
|
||||||
|
/// guessed is which machine a reconnect goes to. Carried from here because a tab has no vault to ask —
|
||||||
|
/// see <c>TerminalTabViewModel.HostId</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal Guid? HostId { get; } = hostId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A connection that has got as far as a named step.</summary>
|
/// <summary>A connection that has got as far as a named step.</summary>
|
||||||
@@ -1391,8 +1468,18 @@ internal sealed partial class VaultViewModel(
|
|||||||
ServerReconnectHandler? reconnect = null,
|
ServerReconnectHandler? reconnect = null,
|
||||||
Func<string, Task>? copyToClipboard = null,
|
Func<string, Task>? copyToClipboard = null,
|
||||||
ConnectionRecorder? connectionLog = null,
|
ConnectionRecorder? connectionLog = null,
|
||||||
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
|
VaultVisibility? visibility = null,
|
||||||
|
ISshReachabilityProbe? reachability = null) : ObservableObject, IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Static and shared, because <see cref="SshReachabilityProbe"/> holds nothing but a budget and every
|
||||||
|
/// call to it is a fresh socket. Last in the parameter list and optional for the reason
|
||||||
|
/// <c>visibility</c> is: every existing caller — which is both heads and every test — goes on meaning
|
||||||
|
/// exactly what it did, and the one that wants to watch what was probed passes a substitute.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly ISshReachabilityProbe DefaultReachability = new SshReachabilityProbe();
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
||||||
@@ -5170,25 +5257,109 @@ internal sealed partial class VaultViewModel(
|
|||||||
OnPropertyChanged(nameof(CollapseAllLabel));
|
OnPropertyChanged(nameof(CollapseAllLabel));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The board, vault by vault: every vault's own groups under its own heading.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Two levels, and the outer one is the vault.</b> See
|
||||||
|
/// <see cref="HostSectionViewModel.VaultHeading"/> for the argument. What it means here is a pass per
|
||||||
|
/// vault rather than one over every group at once: each vault's unfiled hosts and each vault's groups
|
||||||
|
/// are flattened on their own, and the first section that comes back wears the vault's name.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One vault on the board and this is exactly what it was.</b> Not a special case for tidiness — a
|
||||||
|
/// heading naming the only vault there is would be a level of hierarchy with nothing to distinguish,
|
||||||
|
/// and the single-vault board is what most of this application's users have. The count is of vaults
|
||||||
|
/// that are readable <em>and</em> shown, so hiding the second vault takes the headings away with it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
private List<HostSectionViewModel> BuildHostSections()
|
private List<HostSectionViewModel> BuildHostSections()
|
||||||
{
|
{
|
||||||
var shown = Hosts.Where(MatchesHostBoardFilters).ToArray();
|
var shown = Hosts.Where(MatchesHostBoardFilters).ToArray();
|
||||||
var flat = FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group");
|
var vaults = BoardVaults();
|
||||||
|
|
||||||
|
var sections = vaults.Count < 2
|
||||||
|
? SectionsFrom(
|
||||||
|
FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group"),
|
||||||
|
vaultHeading: null)
|
||||||
|
: vaults
|
||||||
|
.SelectMany(vault => SectionsFrom(
|
||||||
|
FlattenIntoSections(
|
||||||
|
[.. shown.Where(row => row.VaultId == vault.VaultId)],
|
||||||
|
ungroupedFirst: true,
|
||||||
|
ungroupedLabel: "No group",
|
||||||
|
vaultId: vault.VaultId),
|
||||||
|
vault.Name.ToUpperInvariant()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Marked on the first heading of the whole board, which is where the mock draws Collapse all /
|
||||||
|
// Expand all — see IsFirstBoardSection and the XAML. The first *heading* rather than the first
|
||||||
|
// section: a vault with no groups at all draws its cards under no heading, and there is nothing on
|
||||||
|
// that row for the control to sit on.
|
||||||
|
for (var index = 0; index < sections.Count; index++)
|
||||||
|
{
|
||||||
|
if (sections[index].Header is not { } first)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sections[index] = new HostSectionViewModel(
|
||||||
|
first with { IsFirstBoardSection = true },
|
||||||
|
sections[index].Hosts)
|
||||||
|
{
|
||||||
|
VaultHeading = sections[index].VaultHeading,
|
||||||
|
};
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The vaults the board draws a heading for, in the order it draws them.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The active vault first and then by name, which is the order <see cref="Groups"/>, the host list and
|
||||||
|
/// the key list are all already sorted into — so the sections come out in the order the rows inside them
|
||||||
|
/// were. Hidden vaults are left out here rather than filtered later, because their absence is what makes
|
||||||
|
/// the headings disappear when one vault is left. See <see cref="IsVaultShown"/>.
|
||||||
|
/// </remarks>
|
||||||
|
private List<(Guid VaultId, string Name)> BoardVaults() =>
|
||||||
|
[
|
||||||
|
.. session.ReadableVaults
|
||||||
|
.Where(vault => IsVaultShown(vault.VaultId))
|
||||||
|
.OrderByDescending(vault => vault.VaultId == session.ActiveVaultId)
|
||||||
|
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
|
||||||
|
.Select(vault => (vault.VaultId, vault.Name)),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regroups one flattened run — a whole board, or one vault's part of it — into sections.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="flat">Headings and rows in draw order, from <see cref="FlattenIntoSections"/>.</param>
|
||||||
|
/// <param name="vaultHeading">
|
||||||
|
/// The vault's name for the first section, or null where the board is not drawing vault headings. When
|
||||||
|
/// it is given, the group headings drop their own vault badge: the name is already on the row above
|
||||||
|
/// them, and printing it twice on every heading is how a two-level board reads as a cluttered one.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// Rows arriving before any heading are the no-groups case — <see cref="FlattenIntoSections"/> hands
|
||||||
|
/// back the hosts alone — and they become one section with a null header, which is what the board's XAML
|
||||||
|
/// reads as "draw the cards and nothing above them". Per vault now rather than for the whole board: a
|
||||||
|
/// vault nobody has made a group in draws its cards flat, under its own name, beside a vault that has.
|
||||||
|
/// </remarks>
|
||||||
|
private static List<HostSectionViewModel> SectionsFrom(List<ISidebarRow> flat, string? vaultHeading)
|
||||||
|
{
|
||||||
|
var sections = new List<HostSectionViewModel>();
|
||||||
|
|
||||||
if (flat.Count == 0)
|
if (flat.Count == 0)
|
||||||
{
|
{
|
||||||
return [];
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Groups.Count == 0)
|
|
||||||
{
|
|
||||||
return [new HostSectionViewModel(null, [.. flat.Cast<HostRowViewModel>()])];
|
|
||||||
}
|
|
||||||
|
|
||||||
var sections = new List<HostSectionViewModel>();
|
|
||||||
SidebarGroupHeader? header = null;
|
SidebarGroupHeader? header = null;
|
||||||
var members = new List<HostRowViewModel>();
|
var members = new List<HostRowViewModel>();
|
||||||
var firstHeading = true;
|
var loose = new List<HostRowViewModel>();
|
||||||
|
|
||||||
foreach (var row in flat)
|
foreach (var row in flat)
|
||||||
{
|
{
|
||||||
@@ -5199,15 +5370,12 @@ internal sealed partial class VaultViewModel(
|
|||||||
sections.Add(new HostSectionViewModel(header, members));
|
sections.Add(new HostSectionViewModel(header, members));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marked on the first heading only, which is where the mock draws Collapse all / Expand
|
header = vaultHeading is null ? next : next with { VaultBadge = string.Empty };
|
||||||
// all — see IsFirstBoardSection and the XAML.
|
|
||||||
header = firstHeading ? next with { IsFirstBoardSection = true } : next;
|
|
||||||
firstHeading = false;
|
|
||||||
members = [];
|
members = [];
|
||||||
}
|
}
|
||||||
else if (row is HostRowViewModel host)
|
else if (row is HostRowViewModel host)
|
||||||
{
|
{
|
||||||
members.Add(host);
|
(header is null ? loose : members).Add(host);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5215,6 +5383,18 @@ internal sealed partial class VaultViewModel(
|
|||||||
{
|
{
|
||||||
sections.Add(new HostSectionViewModel(header, members));
|
sections.Add(new HostSectionViewModel(header, members));
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sections.Add(new HostSectionViewModel(null, loose));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vaultHeading is { Length: > 0 })
|
||||||
|
{
|
||||||
|
sections[0] = new HostSectionViewModel(sections[0].Header, sections[0].Hosts)
|
||||||
|
{
|
||||||
|
VaultHeading = vaultHeading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
@@ -5230,6 +5410,12 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// desktop's first, and there is no reason the two heads have to agree once there are two callers.
|
/// desktop's first, and there is no reason the two heads have to agree once there are two callers.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="ungroupedLabel">What that heading says — "UNGROUPED" on the phone, "No group" here.</param>
|
/// <param name="ungroupedLabel">What that heading says — "UNGROUPED" on the phone, "No group" here.</param>
|
||||||
|
/// <param name="vaultId">
|
||||||
|
/// The vault this run is about, or null for a run over every vault at once. Given, it narrows the
|
||||||
|
/// groups to that vault's own and becomes the fold key of its ungrouped heading — see
|
||||||
|
/// <see cref="SidebarGroupHeader.CollapseKey"/>, which exists because there is now one such heading per
|
||||||
|
/// vault. The phone's single list passes null and is unchanged by any of this.
|
||||||
|
/// </param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>No groups means no headings</b>, on both heads and for the same reason: a keychain nobody has
|
/// <b>No groups means no headings</b>, on both heads and for the same reason: a keychain nobody has
|
||||||
/// filed anything in should look exactly as it did before groups existed, on a screen that draws
|
/// filed anything in should look exactly as it did before groups existed, on a screen that draws
|
||||||
@@ -5240,17 +5426,27 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// left empty.
|
/// left empty.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private List<ISidebarRow> FlattenIntoSections(
|
private List<ISidebarRow> FlattenIntoSections(
|
||||||
IReadOnlyList<HostRowViewModel> shown, bool ungroupedFirst, string ungroupedLabel)
|
IReadOnlyList<HostRowViewModel> shown,
|
||||||
|
bool ungroupedFirst,
|
||||||
|
string ungroupedLabel,
|
||||||
|
Guid? vaultId = null)
|
||||||
{
|
{
|
||||||
var rows = new List<ISidebarRow>();
|
var rows = new List<ISidebarRow>();
|
||||||
|
|
||||||
if (Groups.Count == 0)
|
// This vault's groups where a vault was named, every readable vault's where one was not. A group is
|
||||||
|
// an item of one vault, so a run about one vault has no business drawing another's headings — and a
|
||||||
|
// vault nobody has made a group in falls into the case below, drawing its cards flat.
|
||||||
|
var groups = vaultId is { } only
|
||||||
|
? Groups.Where(group => group.VaultId == only).ToList()
|
||||||
|
: [.. Groups];
|
||||||
|
|
||||||
|
if (groups.Count == 0)
|
||||||
{
|
{
|
||||||
rows.AddRange(shown);
|
rows.AddRange(shown);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
var known = Groups.Select(group => group.EntityId).ToHashSet();
|
var known = groups.Select(group => group.EntityId).ToHashSet();
|
||||||
|
|
||||||
void AddUngrouped() => AddFlatSection(
|
void AddUngrouped() => AddFlatSection(
|
||||||
rows,
|
rows,
|
||||||
@@ -5258,14 +5454,15 @@ internal sealed partial class VaultViewModel(
|
|||||||
null,
|
null,
|
||||||
host => host.Host.GroupId is not { } id || !known.Contains(id),
|
host => host.Host.GroupId is not { } id || !known.Contains(id),
|
||||||
ungroupedLabel,
|
ungroupedLabel,
|
||||||
onlyWhenOccupied: true);
|
onlyWhenOccupied: true,
|
||||||
|
ungroupedKey: vaultId ?? Guid.Empty);
|
||||||
|
|
||||||
if (ungroupedFirst)
|
if (ungroupedFirst)
|
||||||
{
|
{
|
||||||
AddUngrouped();
|
AddUngrouped();
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var group in Groups)
|
foreach (var group in groups)
|
||||||
{
|
{
|
||||||
AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel);
|
AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel);
|
||||||
}
|
}
|
||||||
@@ -5289,13 +5486,19 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <param name="belongs">Which of the shown hosts fall under it.</param>
|
/// <param name="belongs">Which of the shown hosts fall under it.</param>
|
||||||
/// <param name="ungroupedLabel">What the heading says when <paramref name="group"/> is null.</param>
|
/// <param name="ungroupedLabel">What the heading says when <paramref name="group"/> is null.</param>
|
||||||
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
|
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
|
||||||
|
/// <param name="ungroupedKey">
|
||||||
|
/// What the ungrouped heading folds under, where this is one — the vault's id on the desktop's
|
||||||
|
/// per-vault board, <see cref="Guid.Empty"/> on the phone's single list. See
|
||||||
|
/// <see cref="SidebarGroupHeader.CollapseKey"/>.
|
||||||
|
/// </param>
|
||||||
private void AddFlatSection(
|
private void AddFlatSection(
|
||||||
List<ISidebarRow> rows,
|
List<ISidebarRow> rows,
|
||||||
IReadOnlyList<HostRowViewModel> shown,
|
IReadOnlyList<HostRowViewModel> shown,
|
||||||
HostGroupRowViewModel? group,
|
HostGroupRowViewModel? group,
|
||||||
Func<HostRowViewModel, bool> belongs,
|
Func<HostRowViewModel, bool> belongs,
|
||||||
string ungroupedLabel,
|
string ungroupedLabel,
|
||||||
bool onlyWhenOccupied = false)
|
bool onlyWhenOccupied = false,
|
||||||
|
Guid ungroupedKey = default)
|
||||||
{
|
{
|
||||||
var members = shown.Where(belongs).ToArray();
|
var members = shown.Where(belongs).ToArray();
|
||||||
|
|
||||||
@@ -5304,7 +5507,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty);
|
var key = group?.EntityId ?? ungroupedKey;
|
||||||
|
var expanded = !collapsedGroups.Contains(key);
|
||||||
|
|
||||||
rows.Add(new SidebarGroupHeader(
|
rows.Add(new SidebarGroupHeader(
|
||||||
group?.EntityId,
|
group?.EntityId,
|
||||||
@@ -5313,6 +5517,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
expanded)
|
expanded)
|
||||||
{
|
{
|
||||||
VaultBadge = group?.VaultBadge ?? string.Empty,
|
VaultBadge = group?.VaultBadge ?? string.Empty,
|
||||||
|
CollapseKey = key,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!expanded)
|
if (!expanded)
|
||||||
@@ -5458,7 +5663,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var key = header.GroupId ?? Guid.Empty;
|
var key = header.CollapseKey;
|
||||||
|
|
||||||
if (!collapsedGroups.Remove(key))
|
if (!collapsedGroups.Remove(key))
|
||||||
{
|
{
|
||||||
@@ -5490,7 +5695,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
foreach (var header in headers)
|
foreach (var header in headers)
|
||||||
{
|
{
|
||||||
var key = header.GroupId ?? Guid.Empty;
|
var key = header.CollapseKey;
|
||||||
|
|
||||||
if (collapsing)
|
if (collapsing)
|
||||||
{
|
{
|
||||||
@@ -6259,6 +6464,69 @@ internal sealed partial class VaultViewModel(
|
|||||||
ApplyTheChosenHosts();
|
ApplyTheChosenHosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Ctrl-click: the card joins or leaves the set, and the set starts at whatever card is lit.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="row">The card that was Ctrl-clicked.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The lit card was not in the set, and every list this one is modelled on says it should be.</b>
|
||||||
|
/// The board always has a selection — a fresh unlock lights the first host so that CONNECT has something
|
||||||
|
/// under it, see <see cref="SelectionAfterReload"/> — so "one card lit, nothing ticked" is the state
|
||||||
|
/// this screen opens in, not an edge case. Ctrl-clicking a second card there used to produce a set of
|
||||||
|
/// one: the machine the user was looking at was silently left out, and the count said 1 while two cards
|
||||||
|
/// were marked on screen. Delete, Move and Change group all read the set, so the omission was one the
|
||||||
|
/// user could act on without noticing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Only into an <em>empty</em> set, and never the card being clicked. The first is what makes it a
|
||||||
|
/// starting point rather than a card that keeps re-adding itself: once anything is ticked the lit card
|
||||||
|
/// is either already in the set or was deliberately taken out of it. The second is what keeps every
|
||||||
|
/// Ctrl-click visibly doing something — folding the lit card in and then toggling that same card off
|
||||||
|
/// again would leave the set empty and the card still lit, which is a gesture with no effect and no way
|
||||||
|
/// to tell it was heard.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Separate from <see cref="ToggleHostChoice"/> rather than folded into it, because that one is also the
|
||||||
|
/// phone's tap and the phone has no lit card to fold: there a tap only ever reaches the set once a long
|
||||||
|
/// press has already put something in it, and a selection nothing draws is not one anybody can be
|
||||||
|
/// surprised to find in a count.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void ExtendHostChoice(HostRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FoldTheLitCardIn(besides: row);
|
||||||
|
ToggleHostChoice(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts the lit card into an empty set, which is what a Ctrl gesture builds on.</summary>
|
||||||
|
/// <param name="besides">A card to leave out — the one the gesture is about. See
|
||||||
|
/// <see cref="ExtendHostChoice"/> for why.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// The board's order rather than <see cref="Hosts"/>, for the reason Ctrl+A takes it from there: a
|
||||||
|
/// selected host inside a folded section is not on screen, and starting a set with a card nobody can see
|
||||||
|
/// is the version of this that ends in a deletion. <see cref="ApplyTheChosenHosts"/> is left to the
|
||||||
|
/// caller, which runs it once for the whole gesture.
|
||||||
|
/// </remarks>
|
||||||
|
private void FoldTheLitCardIn(HostRowViewModel? besides = null)
|
||||||
|
{
|
||||||
|
if (chosenHostIds.Count > 0
|
||||||
|
|| SelectedHost is not { } lit
|
||||||
|
|| ReferenceEquals(lit, besides)
|
||||||
|
|| !HostBoardOrder.Contains(lit))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chosenHostIds.Add(lit.EntityId);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
|
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -6288,6 +6556,14 @@ internal sealed partial class VaultViewModel(
|
|||||||
{
|
{
|
||||||
chosenHostIds.Clear();
|
chosenHostIds.Clear();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// A band dragged with Ctrl adds to what was already ticked, and on this board "what was already
|
||||||
|
// ticked" starts at the card the grid has lit. Same rule as a Ctrl-click; see ExtendHostChoice
|
||||||
|
// for the whole of the argument. No card is left out here, because a band adds rather than
|
||||||
|
// toggles and cannot undo its own fold.
|
||||||
|
FoldTheLitCardIn();
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var row in rows)
|
foreach (var row in rows)
|
||||||
{
|
{
|
||||||
@@ -8283,12 +8559,25 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// Stores several hosts at once, the way one save does, with the keys they named where those are coming
|
/// Stores several hosts at once, the way one save does, with the keys they named where those are coming
|
||||||
/// too.
|
/// too.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="requests">The hosts to store, and the key files any of them named.</param>
|
||||||
|
/// <param name="vaultId">
|
||||||
|
/// Which vault to write them into, or null for the active one.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">Cancellation.</param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
|
/// For the two importers, which are the only things that produce hosts in bulk. It goes through the same
|
||||||
/// through the same repositories, the same outbox and the same automatic push as saving one — the import
|
/// repositories, the same outbox and the same automatic push as saving one — an import screen decides
|
||||||
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host or a key to
|
/// <em>which</em> hosts and <em>where</em> and nothing else, so there is no second way for a host or a
|
||||||
/// be written and no second place for the sync wiring to be forgotten.
|
/// key to be written and no second place for the sync wiring to be forgotten.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The vault is a parameter rather than the active one, and null still means the active one.</b>
|
||||||
|
/// The <c>ssh_config</c> importer reads a file belonging to this machine's own account and passes null;
|
||||||
|
/// the Hetzner one reads somebody's estate and offers a picker, because forty servers belonging to a
|
||||||
|
/// team are exactly the case where "wherever I happen to be standing" is the wrong answer. Everything
|
||||||
|
/// written by one call goes to one vault — the hosts and any key material with them — so a batch cannot
|
||||||
|
/// end up split across two.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
|
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
|
||||||
@@ -8315,10 +8604,12 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal async Task<ImportOutcome> ImportHostsAsync(
|
internal async Task<ImportOutcome> ImportHostsAsync(
|
||||||
IReadOnlyList<ImportedHostRequest> requests,
|
IReadOnlyList<ImportedHostRequest> requests,
|
||||||
|
Guid? vaultId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(requests);
|
ArgumentNullException.ThrowIfNull(requests);
|
||||||
|
|
||||||
|
var target = vaultId ?? session.ActiveVaultId;
|
||||||
var hosts = 0;
|
var hosts = 0;
|
||||||
var keys = 0;
|
var keys = 0;
|
||||||
var refused = 0;
|
var refused = 0;
|
||||||
@@ -8340,7 +8631,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (keyId, created) = await BindImportedKeyAsync(request, bound, cancellationToken)
|
var (keyId, created) = await BindImportedKeyAsync(
|
||||||
|
request, bound, target, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
if (created)
|
if (created)
|
||||||
@@ -8351,7 +8643,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||||
|
|
||||||
await session.Hosts
|
await session.Hosts
|
||||||
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
|
.CreateAsync(target, host, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
hosts++;
|
hosts++;
|
||||||
@@ -8381,6 +8673,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
|
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
|
||||||
ImportedHostRequest request,
|
ImportedHostRequest request,
|
||||||
Dictionary<string, Guid> bound,
|
Dictionary<string, Guid> bound,
|
||||||
|
Guid vaultId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (request.Key is not { } key || request.KeyPath is not { } path)
|
if (request.Key is not { } key || request.KeyPath is not { } path)
|
||||||
@@ -8407,7 +8700,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var id = await session.SshKeys
|
var id = await session.SshKeys
|
||||||
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
|
.CreateAsync(vaultId, key, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
bound[path] = id;
|
bound[path] = id;
|
||||||
@@ -8482,6 +8775,12 @@ internal sealed partial class VaultViewModel(
|
|||||||
// destroys it, is not a pane anybody should have to read carefully.
|
// destroys it, is not a pane anybody should have to read carefully.
|
||||||
PendingDeletion = null;
|
PendingDeletion = null;
|
||||||
movingHostId = row.EntityId;
|
movingHostId = row.EntityId;
|
||||||
|
|
||||||
|
// ◆ The panel is drawn in the drawer's footer and nowhere else, so raising it means raising the
|
||||||
|
// drawer. Free when this was the drawer's own ⋯ menu — the pane is already open there — and the
|
||||||
|
// whole of what makes the board's context menu entry work, since a right click on a card does not
|
||||||
|
// open anything. Set before the flag, so the pane and its footer arrive in one layout pass.
|
||||||
|
IsHostPaneOpen = true;
|
||||||
IsMovingHost = true;
|
IsMovingHost = true;
|
||||||
Status = string.Empty;
|
Status = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -10967,7 +11266,52 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private sealed class PhaseReporter(Action<SshConnectionPhase> report) : IProgress<SshConnectionPhase>
|
private sealed class PhaseReporter(Action<SshConnectionPhase> report) : IProgress<SshConnectionPhase>
|
||||||
{
|
{
|
||||||
public void Report(SshConnectionPhase value) => report(value);
|
private int furthest = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The furthest phase reported, or null if the handshake never reported one at all.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Kept because a failure cannot be read without it: <see cref="ExplainAsync"/> probes the endpoint
|
||||||
|
/// only for a connection that stopped while reaching, and this is the record of how far it got.
|
||||||
|
/// Nothing else asks — the card is driven by the events, not by this.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>An <see cref="int"/> behind a <see cref="Volatile"/> pair, because the write and the read are
|
||||||
|
/// on different threads.</b> Phases are reported from whichever thread the handshake is on — SSH.NET
|
||||||
|
/// raises host key verification on its own message thread, which is where
|
||||||
|
/// <see cref="SshConnectionPhase.CheckingHostKey"/> and
|
||||||
|
/// <see cref="SshConnectionPhase.Authenticating"/> come from — and this is read on the UI thread
|
||||||
|
/// after the attempt has failed. The <c>await</c> in between orders them in practice; nothing in the
|
||||||
|
/// language says so, and a cached read here would probe a host that had already offered a key.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Monotonic, so a phase cannot go backwards.</b> The enum only ever advances today, and the
|
||||||
|
/// maximum rather than the latest is what keeps that an observation rather than something this type
|
||||||
|
/// relies on: a rekey mid-handshake raising <c>HostKeyReceived</c> a second time must not be able to
|
||||||
|
/// walk the record back to <see cref="SshConnectionPhase.CheckingHostKey"/> from
|
||||||
|
/// <see cref="SshConnectionPhase.OpeningShell"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public SshConnectionPhase? Furthest =>
|
||||||
|
Volatile.Read(ref furthest) is var seen && seen < 0 ? null : (SshConnectionPhase)seen;
|
||||||
|
|
||||||
|
public void Report(SshConnectionPhase value)
|
||||||
|
{
|
||||||
|
var reported = (int)value;
|
||||||
|
|
||||||
|
// Read-compare-write rather than an interlocked maximum, which the BCL has no primitive for. The
|
||||||
|
// race it loses is two phases reported at the same instant from two threads, which this
|
||||||
|
// handshake does not do — it is one connection making one call at a time — and the cost of
|
||||||
|
// losing it would be one phase forgotten rather than a wrong answer.
|
||||||
|
if (reported > Volatile.Read(ref furthest))
|
||||||
|
{
|
||||||
|
Volatile.Write(ref furthest, reported);
|
||||||
|
}
|
||||||
|
|
||||||
|
report(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
|
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
|
||||||
@@ -11005,7 +11349,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
var attempt = new ConnectionAttemptEventArgs(
|
var attempt = new ConnectionAttemptEventArgs(
|
||||||
Guid.CreateVersion7(),
|
Guid.CreateVersion7(),
|
||||||
target.Label,
|
target.Label,
|
||||||
Dialled(target, authentication));
|
Dialled(target, authentication),
|
||||||
|
target.HostId);
|
||||||
|
|
||||||
ConnectionStarting?.Invoke(this, attempt);
|
ConnectionStarting?.Invoke(this, attempt);
|
||||||
Status = $"Connecting to {target.Label}…";
|
Status = $"Connecting to {target.Label}…";
|
||||||
@@ -11240,9 +11585,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
HostAuthentication authentication,
|
HostAuthentication authentication,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Built here rather than inside the call, because the catch blocks below read it: how far the
|
||||||
|
// handshake got is what says whether a failure is worth probing the endpoint over. See ExplainAsync.
|
||||||
|
var reporter = ReporterFor(attempt);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken)
|
await ConnectAndAnnounceAsync(attempt, target, authentication, reporter, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (TimeoutException)
|
catch (TimeoutException)
|
||||||
@@ -11268,10 +11617,87 @@ internal sealed partial class VaultViewModel(
|
|||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
RecordFailure(target, authentication, ConnectionOutcome.Failed);
|
RecordFailure(target, authentication, ConnectionOutcome.Failed);
|
||||||
Abandon(attempt, exception.Message);
|
|
||||||
|
Abandon(
|
||||||
|
attempt,
|
||||||
|
await ExplainAsync(exception, target, reporter, cancellationToken).ConfigureAwait(true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What to tell the user about a connection that failed, having gone and looked when the message alone
|
||||||
|
/// does not say.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Because "Connection has timed out." is what SSH.NET says for two unrelated faults.</b> A machine
|
||||||
|
/// that never answered and a machine that answered TCP and then never spoke SSH arrive here as the same
|
||||||
|
/// five words, and they have nothing in common to do about them — the first is a network, a firewall or a
|
||||||
|
/// wrong address, the second is an <c>sshd</c> that is wedged or a port that is not SSH. That was the
|
||||||
|
/// whole of what this screen could say about the failure users hit most, so it now goes and finds out;
|
||||||
|
/// see <see cref="ISshReachabilityProbe"/> for what it does and deliberately does not do.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Only for a failure that happened while reaching, and the phase is what settles that.</b> The window
|
||||||
|
/// is exactly <see cref="SshConnectionPhase.Reaching"/>, and it is closed at both ends. Past it the
|
||||||
|
/// machine has demonstrably been reached — it offered a host key — so a refused password or a key the
|
||||||
|
/// remote would not take is not a reachability question, and probing the port would answer one nobody
|
||||||
|
/// asked while telling somebody their network is fine when they already know.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>And before it, nothing has been dialled yet, which is the half that would lie.</b> No phase at
|
||||||
|
/// all means the attempt never reached <c>ConnectAsync</c>: a renderer that did not attach, a credential
|
||||||
|
/// that would not decrypt, a private key that would not parse — <c>BuildConnectionInfo</c> throws on that
|
||||||
|
/// last one before the factory reports anything. Probing there finds a healthy port and appends "the SSH
|
||||||
|
/// handshake is what did not finish" to a message about an unreadable key, which is a confident wrong
|
||||||
|
/// answer where there was an accurate one. The phases are reported at the instant each begins and are not
|
||||||
|
/// guesses; see the enum.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The original message is kept and the finding is added to it.</b> What SSH.NET said is the fact, and
|
||||||
|
/// this is a reading of a second measurement — dropping the first would leave nothing to check the second
|
||||||
|
/// against, and would lose the one case where the exception is already specific.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A probe that itself fails changes nothing.</b> The user is being told about a connection that did
|
||||||
|
/// not happen; a diagnostic that cannot run is not a second failure worth reporting, and must not be able
|
||||||
|
/// to replace the first with its own exception. Cancellation is in that: a user who closed the tab while
|
||||||
|
/// this ran is not waiting for the answer.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<string> ExplainAsync(
|
||||||
|
Exception exception,
|
||||||
|
ConnectionTarget target,
|
||||||
|
PhaseReporter reporter,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (reporter.Furthest != SshConnectionPhase.Reaching)
|
||||||
|
{
|
||||||
|
return exception.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var report = await Reachability
|
||||||
|
.ProbeAsync(target.Hostname, target.Port, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
// A probe with nothing to say leaves the message exactly as it was, rather than appending a
|
||||||
|
// space to it. The real one always has a sentence; this is what keeps a substitute that does not
|
||||||
|
// from changing the text of every failure that goes past it.
|
||||||
|
return report.Summary is { Length: > 0 } finding
|
||||||
|
? $"{exception.Message} {finding}"
|
||||||
|
: exception.Message;
|
||||||
|
}
|
||||||
|
catch (Exception probe) when (probe is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
return exception.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="DefaultReachability" />
|
||||||
|
private ISshReachabilityProbe Reachability => reachability ?? DefaultReachability;
|
||||||
|
|
||||||
/// <summary>What a renderer that never attached is reported as.</summary>
|
/// <summary>What a renderer that never attached is reported as.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -11337,6 +11763,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
ConnectionAttemptEventArgs attempt,
|
ConnectionAttemptEventArgs attempt,
|
||||||
ConnectionTarget target,
|
ConnectionTarget target,
|
||||||
HostAuthentication authentication,
|
HostAuthentication authentication,
|
||||||
|
PhaseReporter reporter,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Not reported before the await: the tab is constructed with this step already running — see
|
// Not reported before the await: the tab is constructed with this step already running — see
|
||||||
@@ -11350,7 +11777,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
authentication.Credential);
|
authentication.Credential);
|
||||||
|
|
||||||
var sessionId = await workspace
|
var sessionId = await workspace
|
||||||
.OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
|
.OpenSessionAsync(request, TerminalSize.Default, reporter, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||||
@@ -11381,7 +11808,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
Dialled(target, authentication),
|
Dialled(target, authentication),
|
||||||
facts?.Cipher ?? string.Empty,
|
facts?.Cipher ?? string.Empty,
|
||||||
facts?.HostKeyAlgorithm ?? string.Empty,
|
facts?.HostKeyAlgorithm ?? string.Empty,
|
||||||
authentication.IdentityLabel));
|
authentication.IdentityLabel,
|
||||||
|
target.HostId));
|
||||||
|
|
||||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||||
// for must not wait on a vault write to appear.
|
// for must not wait on a vault write to appear.
|
||||||
|
|||||||
@@ -33,11 +33,15 @@ const SERVER_SESSION_ACTIVATED = 4;
|
|||||||
const SERVER_SESSION_REMOVED = 5;
|
const SERVER_SESSION_REMOVED = 5;
|
||||||
const SERVER_PASTE = 6;
|
const SERVER_PASTE = 6;
|
||||||
const SERVER_FONT_SIZE = 7;
|
const SERVER_FONT_SIZE = 7;
|
||||||
|
const SERVER_CLIPBOARD = 8;
|
||||||
|
const SERVER_OUTPUT_DROPPED = 9;
|
||||||
|
|
||||||
const CLIENT_INPUT = 1;
|
const CLIENT_INPUT = 1;
|
||||||
const CLIENT_ACKNOWLEDGE = 2;
|
const CLIENT_ACKNOWLEDGE = 2;
|
||||||
const CLIENT_RESIZE = 3;
|
const CLIENT_RESIZE = 3;
|
||||||
const CLIENT_FONT_SIZE_STEP = 4;
|
const CLIENT_FONT_SIZE_STEP = 4;
|
||||||
|
const CLIENT_CLIPBOARD_COPY = 5;
|
||||||
|
const CLIENT_CLIPBOARD_PASTE = 6;
|
||||||
|
|
||||||
const HEADER_LENGTH = 5;
|
const HEADER_LENGTH = 5;
|
||||||
const SCROLLBACK_LINES = 5000;
|
const SCROLLBACK_LINES = 5000;
|
||||||
@@ -279,6 +283,66 @@ function handleKey(event) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
── THE RIGHT-CLICK IS A COPY OR A PASTE, AND NEVER A MENU ───────────────────────────────────────────
|
||||||
|
|
||||||
|
What a terminal's right button has meant since the Windows console got QuickEdit and since PuTTY
|
||||||
|
shipped: with something selected it copies and drops the selection, with nothing selected it pastes.
|
||||||
|
No menu, no second click. This page used to answer with the WebView's own context menu — xterm stuffs
|
||||||
|
its helper textarea with the selection on contextmenu precisely so that the browser's Copy entry
|
||||||
|
works — which is correct behaviour for a web page and wrong for a terminal.
|
||||||
|
|
||||||
|
◆ THE CLIPBOARD IS THE HOST'S, IN BOTH DIRECTIONS, AND THE READING IS WHY.
|
||||||
|
|
||||||
|
navigator.clipboard.readText() is gated behind the clipboard-read permission in every Chromium, and an
|
||||||
|
embedded WebView with no PermissionRequested handler answers a permission request by putting a prompt
|
||||||
|
on the screen. Trading a context menu for a permission dialog is not this change. So the page asks and
|
||||||
|
the host answers, exactly as the font-size chords do — see requestFontSizeStep. Writing would have
|
||||||
|
worked from here on Windows, and does not go that way either: a clipboard that is the host's in one
|
||||||
|
direction and the page's in the other is two mechanisms to keep working across WebView2, WebKitGTK and
|
||||||
|
Android's WebView.
|
||||||
|
|
||||||
|
◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.
|
||||||
|
|
||||||
|
preventDefault alone would suppress the menu and still let xterm's own contextmenu handler run, which
|
||||||
|
moves the helper textarea under the pointer at z-index 1000 and leaves the selection sitting in it,
|
||||||
|
selected. Both of those exist to serve the native menu; with no menu they are an invisible textarea
|
||||||
|
floating over the grid and a DOM selection nothing will clear. Taking the event in the capture phase
|
||||||
|
and stopping it there is what keeps the gesture wholly ours.
|
||||||
|
|
||||||
|
rightClickSelectsWord is off for the same reason from the other side — see createSession.
|
||||||
|
*/
|
||||||
|
function handleRightClick(event, sessionId) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = session.term.getSelection();
|
||||||
|
|
||||||
|
if (selection.length > 0) {
|
||||||
|
send(CLIENT_CLIPBOARD_COPY, sessionId, new TextEncoder().encode(selection));
|
||||||
|
|
||||||
|
/*
|
||||||
|
And the selection goes, which is the console's behaviour and is load-bearing rather than cosmetic.
|
||||||
|
It is the only acknowledgement there is that the copy happened — nothing else on screen moves — and
|
||||||
|
it is what makes the *next* right-click a paste. Left standing, a selection somebody had forgotten
|
||||||
|
about would turn every later right-click into a copy of it, and the paste would look broken.
|
||||||
|
*/
|
||||||
|
session.term.clearSelection();
|
||||||
|
} else {
|
||||||
|
send(CLIENT_CLIPBOARD_PASTE, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-clicking a pane is a way of arriving at it. The caret follows, for the reason the paste frame's
|
||||||
|
// own focus() call gives.
|
||||||
|
session.term.focus();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a pane for a session, or returns the one already there.
|
* Builds a pane for a session, or returns the one already there.
|
||||||
*
|
*
|
||||||
@@ -302,6 +366,19 @@ function createSession(sessionId) {
|
|||||||
convertEol: false,
|
convertEol: false,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize,
|
fontSize,
|
||||||
|
|
||||||
|
/*
|
||||||
|
Off, and on macOS that is a change from the default rather than a restatement of it: xterm defaults
|
||||||
|
this to true there, so a right-click would select the word under the pointer. With the gesture
|
||||||
|
meaning copy-or-paste, a right-click that quietly makes a selection is a right-click that copies a
|
||||||
|
word instead of pasting — and only on one platform, which is the worst way for it to be wrong.
|
||||||
|
|
||||||
|
Belt and braces today, because handleRightClick takes the event in the capture phase and xterm never
|
||||||
|
sees it. It is here for the upgrade that moves xterm's handler to mousedown, which is where it
|
||||||
|
already is on the Firefox path.
|
||||||
|
*/
|
||||||
|
rightClickSelectsWord: false,
|
||||||
|
|
||||||
scrollback: SCROLLBACK_LINES,
|
scrollback: SCROLLBACK_LINES,
|
||||||
// Matches terminal.css, so the canvas and the page agree on the background. Both are the v2
|
// Matches terminal.css, so the canvas and the page agree on the background. Both are the v2
|
||||||
// design's terminal surface — `TerminalSurface` in Palette.axaml. See the note in terminal.css.
|
// design's terminal surface — `TerminalSurface` in Palette.axaml. See the note in terminal.css.
|
||||||
@@ -346,6 +423,10 @@ function createSession(sessionId) {
|
|||||||
|
|
||||||
term.attachCustomKeyEventHandler(handleKey);
|
term.attachCustomKeyEventHandler(handleKey);
|
||||||
|
|
||||||
|
// On the pane rather than on xterm's own element, and in the capture phase, which is what puts this
|
||||||
|
// ahead of xterm's handler on the element inside it. See handleRightClick.
|
||||||
|
pane.addEventListener('contextmenu', (event) => handleRightClick(event, sessionId), true);
|
||||||
|
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
|
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
|
||||||
});
|
});
|
||||||
@@ -454,6 +535,24 @@ function handleFrame(buffer) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case SERVER_OUTPUT_DROPPED: {
|
||||||
|
const session = sessions.get(sessionId) ?? createSession(sessionId);
|
||||||
|
|
||||||
|
/*
|
||||||
|
The host could not keep the whole of a flood, and this is the line saying where the gap is. It
|
||||||
|
goes in exactly here, between the bytes either side of it — the socket keeps order within a
|
||||||
|
session, so nothing has to be done to place it.
|
||||||
|
|
||||||
|
Written without an acknowledgement, deliberately. Credit describes what the remote sent and what
|
||||||
|
this page has drawn of it; these bytes came from the host itself, and acknowledging them would
|
||||||
|
hand back credit for output that was never reserved against the window in the first place. The
|
||||||
|
payload arrives already styled and already terminated by a reset — see the host's own remark for
|
||||||
|
why the reset comes first as well.
|
||||||
|
*/
|
||||||
|
session.term.write(payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case SERVER_SESSION_ACTIVATED: {
|
case SERVER_SESSION_ACTIVATED: {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
|
|
||||||
@@ -562,6 +661,35 @@ function handleFrame(buffer) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case SERVER_CLIPBOARD: {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
|
||||||
|
if (!session || payload.length < 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
paste() unconditionally, and that is the whole difference from SERVER_PASTE above. This text came
|
||||||
|
off the system clipboard, which is the case bracketed paste is *for*: readline uses the markers to
|
||||||
|
decide it has been pasted into, and a shell that gets a multi-line command inside them treats every
|
||||||
|
newline as text rather than as "run this". The reasoning that makes a single-line *snippet* worth
|
||||||
|
typing instead — bash marking a bracketed paste as an active region, so the command sits at the
|
||||||
|
prompt looking selected — is the reasoning for a snippet reading as though it had been typed. A
|
||||||
|
clipboard paste is not pretending to be typing, and every other terminal highlights it too.
|
||||||
|
|
||||||
|
No execute flag, either: a paste inserts and stops. Pressing Enter for somebody is what the
|
||||||
|
sidebar's RUN does, and it is a thing they asked for by name.
|
||||||
|
*/
|
||||||
|
session.term.paste(new TextDecoder().decode(payload));
|
||||||
|
|
||||||
|
// The caret goes where the text landed, for the reason SERVER_PASTE's own focus() call gives — half
|
||||||
|
// of it, anyway. Here the other half is already right: the gesture that asked for this was a click
|
||||||
|
// on the pane, so the host's window never took Win32 focus away.
|
||||||
|
session.term.focus();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case SERVER_FONT_SIZE: {
|
case SERVER_FONT_SIZE: {
|
||||||
if (payload.length < 1) {
|
if (payload.length < 1) {
|
||||||
break;
|
break;
|
||||||
@@ -808,4 +936,15 @@ document.addEventListener('visibilitychange', () => {
|
|||||||
window.addEventListener('focus', reconnectIfDown);
|
window.addEventListener('focus', reconnectIfDown);
|
||||||
window.addEventListener('online', reconnectIfDown);
|
window.addEventListener('online', reconnectIfDown);
|
||||||
|
|
||||||
|
/*
|
||||||
|
And nowhere on this page has a context menu, which the panes' own handler nearly finishes on its own: they
|
||||||
|
are positioned at inset 0, so every right-click over a terminal lands on one and is stopped there before it
|
||||||
|
reaches here.
|
||||||
|
|
||||||
|
What is left is the status banner, which is drawn over the bottom of the pane while it has something to say
|
||||||
|
— so a right-click on "Reconnecting the terminal view…" would have been the one place in a terminal window
|
||||||
|
that still opened a browser menu. Prevented and not stopped: there is nothing below this to stop it from.
|
||||||
|
*/
|
||||||
|
document.addEventListener('contextmenu', (event) => event.preventDefault());
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
+8
-2
@@ -24,5 +24,11 @@ These are the UMD builds, so they attach to `globalThis`: `Terminal`, `FitAddon.
|
|||||||
|
|
||||||
1. Download the four files at the new versions and update the table above.
|
1. Download the four files at the new versions and update the table above.
|
||||||
2. Confirm the UMD global names are unchanged.
|
2. Confirm the UMD global names are unchanged.
|
||||||
3. Run the terminal suite, then start the app and open a session: nothing here is covered by the
|
3. Confirm the right-click still belongs to `terminal.js`. Two things about xterm are load-bearing for it
|
||||||
.NET tests, because the renderer they exercise is a `ClientWebSocket` standing in for this page.
|
and neither is a global: `rightClickSelectsWord` has to still be an option — the page sets it false, and
|
||||||
|
an option xterm no longer knows is silently ignored — and xterm has to still register its own right-click
|
||||||
|
handling on `contextmenu`, which `handleRightClick` gets ahead of in the capture phase. A build that moved
|
||||||
|
that to `mousedown`, as it already is on the Firefox path, would put xterm back in the gesture.
|
||||||
|
4. Run the terminal suite, then start the app and open a session: `RendererClipboardTests` runs the page's
|
||||||
|
own logic against a stubbed terminal, so what is left for a person is everything about the real one —
|
||||||
|
`docs/manual-checks.md` 1.11 is that list.
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What one look at an endpoint found, once a connection to it had already failed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Four, because they are the four a user would do something different about.</b> A name that does not
|
||||||
|
/// resolve is a typo or a VPN that is down; a port that refuses is a machine that is up with no <c>sshd</c>
|
||||||
|
/// on it; a port that says nothing at all is a firewall or a machine that is off; and a port that opens is
|
||||||
|
/// the one that matters most, because it means the network is fine and the SSH handshake is what failed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing here is inferred from the exception that started it.</b> Each value is what a socket actually
|
||||||
|
/// did, measured at the moment it was asked, which is what makes it safe for a screen to state as fact —
|
||||||
|
/// the same rule <see cref="SshConnectionPhase"/> is written to.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public enum SshReach
|
||||||
|
{
|
||||||
|
/// <summary>The name does not resolve on this machine, so there was no address to dial.</summary>
|
||||||
|
Unresolved = 0,
|
||||||
|
|
||||||
|
/// <summary>It resolved, and the port sent nothing back before the probe gave up.</summary>
|
||||||
|
NoAnswer = 1,
|
||||||
|
|
||||||
|
/// <summary>It resolved, and the host said at once that nothing is listening there.</summary>
|
||||||
|
Refused = 2,
|
||||||
|
|
||||||
|
/// <summary>It resolved, and something accepted a plain TCP connection on that port.</summary>
|
||||||
|
Open = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What a probe of one endpoint found, and the sentence that says so.</summary>
|
||||||
|
/// <param name="Reach">Which of the four it was.</param>
|
||||||
|
/// <param name="Host">The name or address that was probed, as the connection named it.</param>
|
||||||
|
/// <param name="Port">The port that was probed.</param>
|
||||||
|
/// <param name="Address">
|
||||||
|
/// The address the name resolved to and the probe actually dialled, or null when it did not resolve.
|
||||||
|
/// <para>
|
||||||
|
/// One rather than all of them. A name with several addresses is dialled at whichever the stack picks
|
||||||
|
/// first, and naming the one that was tried is a fact; listing the rest would suggest they were.
|
||||||
|
/// </para>
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Elapsed">How long the probe took, which is the evidence behind <see cref="SshReach.NoAnswer"/>.</param>
|
||||||
|
/// <param name="Summary">The finding in plain words, ready to put in front of somebody.</param>
|
||||||
|
public sealed record SshReachabilityReport(
|
||||||
|
SshReach Reach,
|
||||||
|
string Host,
|
||||||
|
int Port,
|
||||||
|
string? Address,
|
||||||
|
TimeSpan Elapsed,
|
||||||
|
string Summary);
|
||||||
|
|
||||||
|
/// <summary>Looks at an endpoint that would not connect, and reports what is actually wrong with it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Separate from <see cref="ISshConnectionFactory"/> rather than a method on it, because it is not a way of
|
||||||
|
/// connecting: it runs after one has failed, it authenticates nothing, and it must stay usable from a caller
|
||||||
|
/// that has no credential to hand — which the failure path is, by the time it needs this.
|
||||||
|
/// </remarks>
|
||||||
|
public interface ISshReachabilityProbe
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the name and opens a bare TCP connection to the port, to find out which part is broken.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="host">The hostname or address the failed connection named.</param>
|
||||||
|
/// <param name="port">The port it named.</param>
|
||||||
|
/// <param name="cancellationToken">Abandons the probe.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// Never throws for a host that is simply broken — being unreachable is the answer, not an error. A
|
||||||
|
/// caller is already in a failure path and has nothing useful to do with a second exception.
|
||||||
|
/// </remarks>
|
||||||
|
ValueTask<SshReachabilityReport> ProbeAsync(string host, int port, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Why this exists at all: two unrelated failures arrive as the same sentence.</b> SSH.NET reports both
|
||||||
|
/// "the machine never answered" and "the machine answered TCP and then never spoke SSH" as
|
||||||
|
/// <c>SshOperationTimeoutException</c> with the message "Connection has timed out." Those are not the same
|
||||||
|
/// problem and do not have the same fix — one is a network or a firewall, the other is an <c>sshd</c> that
|
||||||
|
/// is wedged or a port that is not SSH at all — and nothing in the exception tells them apart. A socket does,
|
||||||
|
/// in about as long as it takes to open one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A plain TCP connect and a name lookup, and deliberately nothing else.</b> No ping, because ICMP is
|
||||||
|
/// filtered on most of the networks this product is pointed at and an unanswered ping would be evidence of
|
||||||
|
/// nothing. No scan of neighbouring ports: the question is what happened to the connection the user asked
|
||||||
|
/// for, and probing ports nobody asked about is the kind of thing that gets a client flagged by an intrusion
|
||||||
|
/// detector on the very network somebody is trying to work on. No SSH banner read either — that is a second
|
||||||
|
/// handshake against a server that has already failed one, and the open socket has answered the question.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The whole probe is bounded, and the bound is short.</b> By the time this runs the user has already
|
||||||
|
/// waited out a connect timeout, so the budget here is a few seconds spent buying an answer rather than
|
||||||
|
/// another wait to sit through. Exceeding it is itself a finding — nothing came back — so the deadline
|
||||||
|
/// produces a result rather than an error.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SshReachabilityProbe : ISshReachabilityProbe
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// How long the name lookup and the connection attempt get between them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A budget rather than two timeouts, so a slow resolver cannot spend the socket's share and leave the
|
||||||
|
/// interesting half of the probe unrun. It is well under the fifteen this client allows a real connection
|
||||||
|
/// — see <c>SshNetConnectionFactory</c> — because a machine that answers at all answers a bare TCP
|
||||||
|
/// connect in a fraction of what an SSH handshake takes, and everything past that point is waiting to
|
||||||
|
/// confirm silence somebody has already waited out once.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Six rather than two, and the floor under it is not a guess.</b> A refusal is not instant on
|
||||||
|
/// Windows: a closed port on loopback took ~2s to come back as <c>WSAECONNREFUSED</c> when this was
|
||||||
|
/// measured, because the stack retries the SYN before reporting. Anything at or under that turns
|
||||||
|
/// <see cref="SshReach.Refused"/> — a machine that is up — into <see cref="SshReach.NoAnswer"/>, which
|
||||||
|
/// this client would then explain as a firewall. The budget must clear the platform's SYN-retry time
|
||||||
|
/// with room to spare, and a couple of seconds of headroom is what the extra four buy. Shortening this
|
||||||
|
/// without re-measuring that floor reintroduces a wrong answer rather than a slow one.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static readonly TimeSpan Budget = TimeSpan.FromSeconds(6);
|
||||||
|
|
||||||
|
private readonly TimeSpan budget;
|
||||||
|
|
||||||
|
/// <summary>Uses the standard <see cref="Budget"/>.</summary>
|
||||||
|
public SshReachabilityProbe()
|
||||||
|
: this(Budget)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="budget">
|
||||||
|
/// How long the whole probe gets. A test uses this to keep a deliberately silent endpoint from costing
|
||||||
|
/// the suite six seconds; nothing in the application passes one.
|
||||||
|
/// </param>
|
||||||
|
public SshReachabilityProbe(TimeSpan budget) => this.budget = budget;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask<SshReachabilityReport> ProbeAsync(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||||
|
|
||||||
|
var clock = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
deadline.CancelAfter(budget);
|
||||||
|
|
||||||
|
var address = await ResolveAsync(host, deadline.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (address is null)
|
||||||
|
{
|
||||||
|
return Report(SshReach.Unresolved, host, port, address: null, clock.Elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
var reach = await ConnectAsync(address, port, deadline.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Report(reach, host, port, address.ToString(), clock.Elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The address the connection would have been made to, or null if the name does not resolve.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An address typed in rather than a name costs no lookup: <see cref="IPAddress.TryParse(string, out IPAddress)"/>
|
||||||
|
/// settles it here, where <see cref="Dns"/> would go and ask the resolver about a string it is going to
|
||||||
|
/// hand straight back. That matters for the report as much as for the speed — a literal that "resolved"
|
||||||
|
/// would be a fact this probe invented about a machine it never asked anybody about.
|
||||||
|
/// </remarks>
|
||||||
|
private static async ValueTask<IPAddress?> ResolveAsync(string host, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (IPAddress.TryParse(host, out var literal))
|
||||||
|
{
|
||||||
|
return literal;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// First rather than a preference between v4 and v6: this is reporting on the connection that
|
||||||
|
// failed, and the stack picked from this same list in this same order when it made it.
|
||||||
|
return addresses.Length == 0 ? null : addresses[0];
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is SocketException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
// A resolver that refuses and a resolver that never answers are the same finding here: there is
|
||||||
|
// no address, so there was nothing to dial. Which of the two it was is a DNS question, and this
|
||||||
|
// probe would be guessing at the answer.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The socket is opened and dropped without a byte being sent. A TCP handshake is the whole question —
|
||||||
|
/// whether anything is listening — and writing to a server that is about to be told nothing is wanted
|
||||||
|
/// would leave a connection attempt in its log that the user did not make.
|
||||||
|
/// </remarks>
|
||||||
|
private static async ValueTask<SshReach> ConnectAsync(
|
||||||
|
IPAddress address,
|
||||||
|
int port,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return SshReach.Open;
|
||||||
|
}
|
||||||
|
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionRefused)
|
||||||
|
{
|
||||||
|
return SshReach.Refused;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is SocketException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Everything else the stack can say here — unreachable, timed out, the deadline above — comes to
|
||||||
|
// the same account of it: the port never answered. Naming the OS error instead would be trading a
|
||||||
|
// sentence somebody can act on for one they would have to look up.
|
||||||
|
return SshReach.NoAnswer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SshReachabilityReport Report(
|
||||||
|
SshReach reach,
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
string? address,
|
||||||
|
TimeSpan elapsed)
|
||||||
|
=> new(reach, host, port, address, elapsed, Describe(reach, host, port, address, elapsed));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The finding as a sentence, saying what was measured and then what it means.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Measurement first in every case, on the rule the rest of this product's failure text follows: the
|
||||||
|
/// fact is what the user can check and act on, and the interpretation after it is this client's reading
|
||||||
|
/// of that fact. Each reading is hedged exactly as far as it deserves — a refusal has one common cause
|
||||||
|
/// and names it, silence has three and lists them, and an open port names the class of problem rather
|
||||||
|
/// than pretending to know which member of it this is.
|
||||||
|
/// </remarks>
|
||||||
|
private static string Describe(SshReach reach, string host, int port, string? address, TimeSpan elapsed)
|
||||||
|
{
|
||||||
|
var seconds = elapsed.TotalSeconds.ToString("0.#", CultureInfo.CurrentCulture);
|
||||||
|
var where = address is null || string.Equals(address, host, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? host
|
||||||
|
: $"{host} ({address})";
|
||||||
|
|
||||||
|
return reach switch
|
||||||
|
{
|
||||||
|
SshReach.Unresolved =>
|
||||||
|
$"The name {host} does not resolve on this machine, so nothing was ever dialled. Check the "
|
||||||
|
+ "spelling, or whether the VPN or DNS server this name depends on is up.",
|
||||||
|
|
||||||
|
SshReach.Refused =>
|
||||||
|
$"{where} is reachable and refused port {port} immediately: the machine is up and nothing is "
|
||||||
|
+ "listening there. Either sshd is not running, or this is the wrong port.",
|
||||||
|
|
||||||
|
SshReach.NoAnswer =>
|
||||||
|
$"{where} sent nothing back on port {port} within {seconds}s. A machine that was up and had "
|
||||||
|
+ "nothing listening would have refused at once, so this is a firewall dropping the "
|
||||||
|
+ "packets, a machine that is off, or the wrong address.",
|
||||||
|
|
||||||
|
_ =>
|
||||||
|
$"Port {port} on {where} accepts connections, so the network and the machine are fine and "
|
||||||
|
+ "something is listening. The SSH handshake is what did not finish: an sshd that is "
|
||||||
|
+ "overloaded or wedged, a middlebox holding the connection open, or a service on this "
|
||||||
|
+ "port that is not SSH.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,33 @@
|
|||||||
namespace DodoSSH.Client.Terminal;
|
namespace DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Credit-based flow control over one terminal session's output.
|
/// Credit-based flow control over what has been sent to the renderer and not yet drawn.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This is what makes <c>yes</c> survivable. A terminal emulator renders at 60 Hz at best, while a
|
/// A terminal emulator renders at 60 Hz at best, while a remote process can produce output as fast as
|
||||||
/// remote process can produce output as fast as the network allows. Without a limit the difference
|
/// the network allows. The difference has to accumulate somewhere, and the one place it must never
|
||||||
/// accumulates somewhere — an unbounded queue in the client, or an ever-growing scrollback — and the
|
/// accumulate is inside the WebView: xterm queues whatever it is written and nothing on this side can
|
||||||
/// application's memory grows until it dies.
|
/// see how deep that queue is, so a page told to draw faster than it can grows until the tab dies.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The mechanism: the renderer is granted a window of bytes it is allowed to be behind by. Each byte
|
/// The mechanism: the renderer is granted a window of bytes it is allowed to be behind by. Each byte
|
||||||
/// sent to it consumes credit; each byte it reports having actually rendered returns credit. When
|
/// sent to it consumes credit; each byte it reports having actually rendered returns credit. When
|
||||||
/// credit reaches zero the pump <b>stops reading the SSH channel</b>. That closes SSH's own receive
|
/// credit reaches zero the pump sends nothing more until some comes back.
|
||||||
/// window, which makes the remote <c>sshd</c> block on write, which propagates the backpressure all
|
|
||||||
/// the way to the process producing the output. Nothing buffers without bound because nothing is read
|
|
||||||
/// that cannot be delivered.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The window has to be large enough that a normal burst never stalls and small enough to bound
|
/// ◆ <b>What this does not do is bound what the remote has sent</b>, and it used to be written as
|
||||||
/// memory. 256 KiB is roughly a screenful of dense output many times over, and it caps a session's
|
/// though it did — the claim was that a pump which stops reading closes SSH's own receive window and
|
||||||
/// in-flight cost at a quarter of a megabyte.
|
/// blocks the remote <c>sshd</c>. It does not, because SSH.NET re-opens that window as bytes arrive
|
||||||
|
/// rather than as they are consumed. Holding output back therefore never reached the remote at all;
|
||||||
|
/// it only chose which buffer the flood landed in, and the one it landed in had no ceiling. The bound
|
||||||
|
/// that was missing now lives in <see cref="OutputBacklog"/>, which is also where the evidence for
|
||||||
|
/// this paragraph is written down.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The window has to be large enough that a normal burst never stalls and small enough to bound what
|
||||||
|
/// the page is holding. 256 KiB is roughly a screenful of dense output many times over, and it caps a
|
||||||
|
/// session's in-flight cost at a quarter of a megabyte.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class CreditWindow
|
public sealed class CreditWindow
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
namespace DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One session's bounded queue of output waiting for the renderer, oldest first.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>This is where a session's memory is actually bounded, and it exists because the obvious place
|
||||||
|
/// cannot do it.</b> The design used to be that <see cref="CreditWindow"/> bounded everything: the pump
|
||||||
|
/// stopped reading the SSH channel when the renderer fell behind, SSH's own receive window closed, and the
|
||||||
|
/// remote blocked on write. The middle step is not true of the library this client uses.
|
||||||
|
/// <c>Renci.SshNet.Channels.Channel.OnData</c> calls its window adjustment <em>before</em> raising
|
||||||
|
/// <c>DataReceived</c> and re-opens the channel window as soon as it drops below one packet, and
|
||||||
|
/// <c>ShellStream</c>'s own handler copies the bytes into a growable buffer without blocking and without a
|
||||||
|
/// ceiling. So a reader that stops reading does not slow the remote down by one byte; it only decides
|
||||||
|
/// whose buffer the flood accumulates in — and SSH.NET's grows by doubling and does not give the memory
|
||||||
|
/// back until the stream is disposed. A remote running <c>yes</c> took the process with it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// So the pump now reads continuously, which is what keeps SSH.NET's buffer empty, and the backlog it
|
||||||
|
/// reads into is this one, with a hard capacity. When the renderer cannot keep up the <em>oldest</em>
|
||||||
|
/// bytes go rather than the newest, because the newest are the ones somebody is waiting to see: the tail
|
||||||
|
/// of a flood is the prompt that comes back after it. Dropping is announced rather than silent — see
|
||||||
|
/// <see cref="Take"/> — and it is the honest end of the trade, since output arriving faster than the
|
||||||
|
/// terminal can draw it was going to scroll off a 5,000-line scrollback regardless.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The credit window is still here and still does its own job: it bounds what is in flight <em>to the
|
||||||
|
/// renderer</em>, which is the buffer inside the WebView that nothing on this side can see. What changed
|
||||||
|
/// is that it no longer pretends to bound what is in flight from the remote.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OutputBacklog
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Default capacity: 4 MiB.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Sixteen times the credit window, so nothing an ordinary burst produces is ever dropped — a
|
||||||
|
/// screenful of dense output is kilobytes, and a build that scrolls for a second is well under this.
|
||||||
|
/// It is a per-session cost, and it is the whole of what a runaway remote can take, which is the
|
||||||
|
/// number worth choosing against: twenty tabs all flooding at once is 80 MiB and survivable, where the
|
||||||
|
/// same twenty unbounded was whatever the machine had.
|
||||||
|
/// </remarks>
|
||||||
|
public const int DefaultCapacityBytes = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
private readonly Lock gate = new();
|
||||||
|
private readonly Queue<byte[]> segments = new();
|
||||||
|
private readonly int capacityBytes;
|
||||||
|
|
||||||
|
/// <summary>Signalled when output arrives at an empty backlog, and when it is completed.</summary>
|
||||||
|
private TaskCompletionSource available = CreateSignal();
|
||||||
|
|
||||||
|
/// <summary>How much of the head segment has already been taken.</summary>
|
||||||
|
private int head;
|
||||||
|
|
||||||
|
private int buffered;
|
||||||
|
private long droppedSinceTake;
|
||||||
|
private long droppedTotal;
|
||||||
|
private bool completed;
|
||||||
|
|
||||||
|
/// <param name="capacityBytes">How many unsent bytes to hold before dropping the oldest.</param>
|
||||||
|
public OutputBacklog(int capacityBytes = DefaultCapacityBytes)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(capacityBytes, 1);
|
||||||
|
|
||||||
|
this.capacityBytes = capacityBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How many unsent bytes are held.</summary>
|
||||||
|
public int Buffered
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return buffered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How many bytes this backlog has discarded over its whole life.</summary>
|
||||||
|
public long DroppedTotal
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return droppedTotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the producer has said there will be no more output.</summary>
|
||||||
|
public bool IsCompleted
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds output, dropping the oldest already held if that puts the backlog over capacity.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Takes ownership of <paramref name="segment"/>; the caller must not write to it afterwards.
|
||||||
|
/// Appending to a completed backlog does nothing rather than throwing, because a producer finding out
|
||||||
|
/// it has been shut down is a race rather than a mistake.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="segment">Bytes read from the remote, in order.</param>
|
||||||
|
public void Append(byte[] segment)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(segment);
|
||||||
|
|
||||||
|
if (segment.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (completed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wasEmpty = buffered == 0;
|
||||||
|
|
||||||
|
segments.Enqueue(segment);
|
||||||
|
buffered += segment.Length;
|
||||||
|
|
||||||
|
// Never the segment just added, however large it is. A read bigger than the whole capacity is
|
||||||
|
// possible if somebody configures one, and a backlog that answered every take with nothing
|
||||||
|
// would stall the session rather than bound it.
|
||||||
|
while (buffered > capacityBytes && segments.Count > 1)
|
||||||
|
{
|
||||||
|
var oldest = segments.Dequeue();
|
||||||
|
var discarded = oldest.Length - head;
|
||||||
|
|
||||||
|
head = 0;
|
||||||
|
buffered -= discarded;
|
||||||
|
droppedSinceTake += discarded;
|
||||||
|
droppedTotal += discarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only on the transition, because a waiter that found bytes here never captured the signal in
|
||||||
|
// the first place — see WaitForOutputAsync, which checks under this same lock. Signalling on
|
||||||
|
// every append would allocate a TaskCompletionSource per read of a session doing megabytes a
|
||||||
|
// second, for a waiter that is not there.
|
||||||
|
if (wasEmpty)
|
||||||
|
{
|
||||||
|
Signal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves the oldest output held into <paramref name="destination"/>, as much of it as will fit.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Into a caller's span rather than into an array of its own, because this is the hot path: the pump
|
||||||
|
/// writes straight into the frame it is about to send, so a terminal showing a gigabyte costs one copy
|
||||||
|
/// of it here rather than two.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <paramref name="dropped"/> is what makes a drop visible instead of silent. It counts the bytes
|
||||||
|
/// discarded since the previous take, which is exactly the gap sitting immediately before the bytes
|
||||||
|
/// being handed over now. The caller writes a line saying so, and it has to — the alternative is a
|
||||||
|
/// terminal quietly showing a different transcript from the one the remote sent. It is reported even
|
||||||
|
/// when nothing is taken, so a drop at the very end of a session is still announced.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="destination">Where the bytes go, oldest first.</param>
|
||||||
|
/// <param name="dropped">Bytes discarded since the previous take, and zero on the ordinary path.</param>
|
||||||
|
/// <returns>How many bytes were written, which is zero when nothing is held.</returns>
|
||||||
|
public int TakeInto(Span<byte> destination, out long dropped)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
dropped = droppedSinceTake;
|
||||||
|
droppedSinceTake = 0;
|
||||||
|
|
||||||
|
var take = Math.Min(destination.Length, buffered);
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
while (offset < take)
|
||||||
|
{
|
||||||
|
var oldest = segments.Peek();
|
||||||
|
var fromThis = Math.Min(oldest.Length - head, take - offset);
|
||||||
|
|
||||||
|
oldest.AsSpan(head, fromThis).CopyTo(destination[offset..]);
|
||||||
|
|
||||||
|
offset += fromThis;
|
||||||
|
head += fromThis;
|
||||||
|
buffered -= fromThis;
|
||||||
|
|
||||||
|
if (head == oldest.Length)
|
||||||
|
{
|
||||||
|
_ = segments.Dequeue();
|
||||||
|
head = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return take;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Says there will be no more output, releasing anything waiting for some.</summary>
|
||||||
|
public void Complete()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
completed = true;
|
||||||
|
Signal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Waits until there is output to take, or until there will never be any.</summary>
|
||||||
|
/// <param name="cancellationToken">Abandons the wait.</param>
|
||||||
|
/// <returns><see langword="false"/> once the backlog is completed and empty.</returns>
|
||||||
|
public async ValueTask<bool> WaitForOutputAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
Task signal;
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (buffered > 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completed)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
signal = available.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
await signal.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Completed inside the lock so a waiter cannot miss the transition, and replaced there too so the
|
||||||
|
/// next wait is on a signal that has not already fired. Continuations run asynchronously, so nothing
|
||||||
|
/// resumes while the lock is held.
|
||||||
|
/// </remarks>
|
||||||
|
private void Signal()
|
||||||
|
{
|
||||||
|
_ = available.TrySetResult();
|
||||||
|
available = CreateSignal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskCompletionSource CreateSignal() =>
|
||||||
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
}
|
||||||
@@ -105,6 +105,23 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
||||||
|
|
||||||
|
/// <summary>Raised when the page has text for the system clipboard.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A right-click over a selection. Raised on the socket's receive loop, like
|
||||||
|
/// <see cref="FontSizeStepRequested"/> and with the same obligation on a handler that touches a view
|
||||||
|
/// model — and the clipboard itself is a UI-thread object on more than one platform, so this one has to
|
||||||
|
/// be marshalled even where nothing is bound to.
|
||||||
|
/// </remarks>
|
||||||
|
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
|
||||||
|
|
||||||
|
/// <summary>Raised when the page wants the system clipboard's text pasted into one of its panes.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A right-click with nothing selected. Raised on the socket's receive loop; see
|
||||||
|
/// <see cref="ClipboardCopyRequested"/>. The answer goes back as
|
||||||
|
/// <see cref="TerminalServerOpcode.Clipboard"/>, which is <see cref="TerminalWorkspace"/>'s to send.
|
||||||
|
/// </remarks>
|
||||||
|
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised after a socket attaches — the first one, and every later takeover.
|
/// Raised after a socket attaches — the first one, and every later takeover.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -463,16 +480,8 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Answered before the session lookup, because it is the one frame that is not about a session. The
|
if (Announce(opcode, sessionId, payload))
|
||||||
// page sends whichever id it had to hand, and a chord pressed in a terminal whose shell has just
|
|
||||||
// ended is still a request to make the text bigger.
|
|
||||||
if ((TerminalClientOpcode)opcode is TerminalClientOpcode.FontSizeStep)
|
|
||||||
{
|
{
|
||||||
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
|
|
||||||
{
|
|
||||||
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,6 +527,59 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the frames no pump needs to see, and says whether this was one of them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Answered before the session lookup, because none of these three touches a pump. A text-size chord is
|
||||||
|
/// about the person reading rather than about one terminal, and the page sends whichever id it had to
|
||||||
|
/// hand. A copy is about the machine's clipboard. A paste request is answered by sending a frame back
|
||||||
|
/// rather than by writing to a pty, and <see cref="TerminalWorkspace.SendClipboardAsync"/> does its own
|
||||||
|
/// liveness check — which is the one that can honestly refuse.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Which is also what keeps all three working in a pane whose shell has just ended, and that is when
|
||||||
|
/// they are most wanted: squinting at the message a remote left behind is exactly when somebody makes
|
||||||
|
/// the text bigger or copies the line. Dropping the frame at the pump lookup would have made a dead tab
|
||||||
|
/// the one place the gesture silently did nothing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private bool Announce(byte opcode, uint sessionId, ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
switch ((TerminalClientOpcode)opcode)
|
||||||
|
{
|
||||||
|
case TerminalClientOpcode.FontSizeStep:
|
||||||
|
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
|
||||||
|
{
|
||||||
|
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case TerminalClientOpcode.ClipboardCopy:
|
||||||
|
// An empty copy is dropped rather than forwarded. The clipboard is shared with everything
|
||||||
|
// else on the machine, so "copy nothing" would be a gesture that quietly destroys whatever
|
||||||
|
// was on it — and the page does not send one, which makes this a guard against a frame
|
||||||
|
// arriving from something other than the page we shipped.
|
||||||
|
if (payload.Length > 0)
|
||||||
|
{
|
||||||
|
ClipboardCopyRequested?.Invoke(
|
||||||
|
this,
|
||||||
|
new TerminalClipboardCopyEventArgs(Encoding.UTF8.GetString(payload)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case TerminalClientOpcode.ClipboardPaste:
|
||||||
|
ClipboardPasteRequested?.Invoke(this, new TerminalClipboardPasteEventArgs(sessionId));
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Computes the <c>Sec-WebSocket-Accept</c> value RFC 6455 §4.2.2 requires.
|
/// Computes the <c>Sec-WebSocket-Accept</c> value RFC 6455 §4.2.2 requires.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -95,6 +95,57 @@ public enum TerminalServerOpcode : byte
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
FontSize = 7,
|
FontSize = 7,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The system clipboard's text, for this session's pane to paste. Payload is UTF-8.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Sent in answer to <see cref="TerminalClientOpcode.ClipboardPaste"/>, which is the only thing that
|
||||||
|
/// asks for it: a right-click over a pane with nothing selected. The host reads the clipboard because
|
||||||
|
/// the page cannot — see that opcode for why not.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Not <see cref="Paste"/>, and the difference is the bracketing.</b> That opcode carries a snippet
|
||||||
|
/// off the sidebar, and the page deliberately <em>types</em> a single-line one rather than bracketing
|
||||||
|
/// it, because bash marks a bracketed paste as an active region and a snippet should read as though it
|
||||||
|
/// had been typed. A clipboard paste is the case those markers are for — readline uses them to decide it
|
||||||
|
/// has been pasted into — so this one is always bracketed. One opcode for both would need a flag
|
||||||
|
/// meaning "which of the two kinds of paste is this", which is the same distinction spelled less
|
||||||
|
/// clearly, on a payload that already has a flag byte meaning something else.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// No flag byte, therefore, and no Enter: a clipboard paste inserts and stops. Pressing Enter for
|
||||||
|
/// somebody is what the sidebar's RUN does, and it is a thing they asked for by name.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
Clipboard = 8,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A line for the pane saying that output was dropped. Payload is UTF-8, already styled.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Written into the pane exactly where it arrives, which is where the gap is — the socket keeps order
|
||||||
|
/// within a session, so this frame lands between the bytes before the drop and the bytes after it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Its own opcode rather than a few bytes spliced into an <see cref="Output"/> frame</b>, which is
|
||||||
|
/// what it was first, and the reason is that the two are not the same kind of thing. Output is what the
|
||||||
|
/// remote said, and this is what this application says about it. Keeping them apart means the host's
|
||||||
|
/// own sentence is never mistaken for the transcript — by anything reading a frame, and in particular
|
||||||
|
/// by the arithmetic that has to hold if dropping is to stay honest: every byte read from the remote is
|
||||||
|
/// either in an <see cref="Output"/> payload or counted in <c>TerminalSessionPump.BytesDropped</c>, and
|
||||||
|
/// a notice inside the output stream would put this process's words on the left of that equation.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Not acknowledged, and so not part of the credit window: credit accounts for what the remote sent
|
||||||
|
/// and what the renderer has drawn of it. A notice is a few dozen bytes the host chose to add, and
|
||||||
|
/// billing them to a window that is meant to describe the remote would make the number mean two
|
||||||
|
/// things.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
OutputDropped = 9,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Frames the renderer sends to the host.</summary>
|
/// <summary>Frames the renderer sends to the host.</summary>
|
||||||
@@ -131,6 +182,52 @@ public enum TerminalClientOpcode : byte
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
FontSizeStep = 4,
|
FontSizeStep = 4,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Text to put on the system clipboard. Payload is UTF-8.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A right-click over a pane with something selected. What gets copied is the selection, and only the
|
||||||
|
/// page knows what that is — xterm draws it into a canvas and the host never sees a glyph of it — so it
|
||||||
|
/// is the text that travels here rather than a request for it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Through the host rather than through the page's own <c>navigator.clipboard</c>.</b> Writing the
|
||||||
|
/// clipboard from the page would work on Windows, where the renderer is Blink and
|
||||||
|
/// <c>clipboard-write</c> is granted to a focused document without asking. It is the <em>reading</em>
|
||||||
|
/// that cannot be done that way — see <see cref="ClipboardPaste"/> — and a clipboard that is the host's
|
||||||
|
/// in one direction and the page's in the other is two mechanisms to keep working across WebView2,
|
||||||
|
/// WebKitGTK and Android's WebView. The host has owned a clipboard delegate since the keychain grew its
|
||||||
|
/// COPY buttons; this is that one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The session id is the pane the selection came from. Nothing routes on it — a clipboard is not a
|
||||||
|
/// property of one terminal — and it is carried because every frame carries one.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
ClipboardCopy = 5,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The page asking for the clipboard's text, to paste into this session. No payload.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A right-click over a pane with nothing selected. The answer arrives as
|
||||||
|
/// <see cref="TerminalServerOpcode.Clipboard"/>, addressed to the session id this frame carried, so the
|
||||||
|
/// text lands in the pane the pointer was over rather than in whichever one the host believes is
|
||||||
|
/// selected. They agree in every arrangement that exists today, because only the active pane is
|
||||||
|
/// visible — but the page is the one that knows, so the page is the one that says.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A request rather than the page reading the clipboard itself, and this is the half with no
|
||||||
|
/// alternative.</b> <c>navigator.clipboard.readText()</c> is gated behind the <c>clipboard-read</c>
|
||||||
|
/// permission in every Chromium, and an embedded WebView answers a permission request it has no handler
|
||||||
|
/// for by putting a prompt on the screen. Trading a context menu for a permission dialog is not the
|
||||||
|
/// change this was.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
ClipboardPaste = 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Channels;
|
|
||||||
using DodoSSH.Client.Ssh;
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Terminal;
|
namespace DodoSSH.Client.Terminal;
|
||||||
@@ -49,6 +49,16 @@ public sealed class TerminalPumpOptions
|
|||||||
|
|
||||||
/// <summary>How far behind the renderer may fall, in bytes.</summary>
|
/// <summary>How far behind the renderer may fall, in bytes.</summary>
|
||||||
public int WindowBytes { get; init; } = CreditWindow.DefaultWindowBytes;
|
public int WindowBytes { get; init; } = CreditWindow.DefaultWindowBytes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How much unsent output to hold for a renderer that is behind, before dropping the oldest.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the number that bounds a session's memory, and <see cref="OutputBacklog"/> says at length
|
||||||
|
/// why it rather than <see cref="WindowBytes"/> is the one that does. Larger drops less and shows a
|
||||||
|
/// flood's tail later; smaller drops sooner and keeps the terminal closer to live.
|
||||||
|
/// </remarks>
|
||||||
|
public int BacklogBytes { get; init; } = OutputBacklog.DefaultCapacityBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -56,27 +66,45 @@ public sealed class TerminalPumpOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Credit is reserved before reading, never after. That ordering is the whole design: because the pump
|
/// ◆ <b>The pump reads the shell channel continuously and never stops, which is deliberate rather than
|
||||||
/// cannot read more than the renderer has room for, the coalescing buffer is bounded by the credit
|
/// careless.</b> It used to stop as soon as the renderer fell behind, on the reasoning that a reader
|
||||||
/// window rather than by how fast the remote can talk. Reserving after reading would leave an
|
/// which does not read closes SSH's receive window, blocks the remote <c>sshd</c> on write, and blocks
|
||||||
/// unbounded queue between the socket and the screen, which is the failure mode this exists to
|
/// the process producing the output in turn — backpressure all the way to the source, with no custom
|
||||||
/// prevent.
|
/// protocol. That reasoning is wrong for this client's SSH library, and wrong in the direction that costs
|
||||||
|
/// memory: SSH.NET re-opens the channel window when bytes <em>arrive</em> rather than when they are
|
||||||
|
/// consumed, and keeps everything it has been handed in a growable array it does not give back. Not
|
||||||
|
/// reading slowed nothing down; it only moved the flood into a buffer with no ceiling.
|
||||||
|
/// <see cref="OutputBacklog"/> carries the full account.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// When credit runs out the pump stops reading. SSH's own receive window then closes, the remote
|
/// So there are two bounds now, each enforced where it can be. The backlog bounds what is waiting to be
|
||||||
/// <c>sshd</c> blocks on write, and the process producing output blocks in turn — backpressure all the
|
/// sent, by dropping its oldest bytes and saying so. The credit window bounds what has been sent and not
|
||||||
/// way to the source, with no custom protocol.
|
/// yet drawn — the buffer inside the WebView that nothing on this side can measure — and the flush loop
|
||||||
|
/// reserves against it before every frame, holding off while there is none. Reading is no longer part of
|
||||||
|
/// either, because reading was never what the remote was waiting on.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class TerminalSessionPump : IAsyncDisposable
|
public sealed class TerminalSessionPump : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The most output one frame carries.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Under the 85,000-byte threshold at which the runtime allocates on the large object heap, and that is
|
||||||
|
/// the whole of why the number is here: a frame is allocated per send, and a session doing megabytes a
|
||||||
|
/// second would otherwise put a quarter-megabyte array on the LOH sixty times a second, which is not
|
||||||
|
/// compacted and is collected only with a generation 2 pass. Below the threshold the same traffic is
|
||||||
|
/// generation 0 garbage that costs nothing to collect. It is a ceiling rather than a target — a quiet
|
||||||
|
/// terminal sends whatever it has, which is a few bytes.
|
||||||
|
/// </remarks>
|
||||||
|
private const int MaximumFrameBytes = 64 * 1024;
|
||||||
|
|
||||||
private readonly uint sessionId;
|
private readonly uint sessionId;
|
||||||
private readonly ISshShellSession session;
|
private readonly ISshShellSession session;
|
||||||
private readonly ITerminalTransport transport;
|
private readonly ITerminalTransport transport;
|
||||||
private readonly TimeProvider clock;
|
private readonly TimeProvider clock;
|
||||||
private readonly TerminalPumpOptions options;
|
private readonly TerminalPumpOptions options;
|
||||||
private readonly Channel<byte[]> pending = Channel.CreateUnbounded<byte[]>(
|
private readonly OutputBacklog backlog;
|
||||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
|
||||||
|
|
||||||
private readonly CancellationTokenSource lifetime = new();
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
private int disposed;
|
private int disposed;
|
||||||
@@ -104,6 +132,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
this.options = options ?? new TerminalPumpOptions();
|
this.options = options ?? new TerminalPumpOptions();
|
||||||
|
|
||||||
Credits = new CreditWindow(this.options.WindowBytes);
|
Credits = new CreditWindow(this.options.WindowBytes);
|
||||||
|
backlog = new OutputBacklog(this.options.BacklogBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>This session's flow-control window.</summary>
|
/// <summary>This session's flow-control window.</summary>
|
||||||
@@ -112,6 +141,19 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
/// <summary>Total bytes read from the remote, for the throughput harness.</summary>
|
/// <summary>Total bytes read from the remote, for the throughput harness.</summary>
|
||||||
public long BytesRead { get; private set; }
|
public long BytesRead { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total bytes this session discarded because the renderer could not keep up with the remote.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Zero for every ordinary session, and the number to look at first when somebody reports missing
|
||||||
|
/// output. The user is told as it happens — a dropped run puts a line in the pane rather than a gap —
|
||||||
|
/// so this is for a test or a report rather than for the interface.
|
||||||
|
/// </remarks>
|
||||||
|
public long BytesDropped => backlog.DroppedTotal;
|
||||||
|
|
||||||
|
/// <summary>Bytes read from the remote and not yet sent to the renderer.</summary>
|
||||||
|
public int BytesPending => backlog.Buffered;
|
||||||
|
|
||||||
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
||||||
public long FramesSent { get; private set; }
|
public long FramesSent { get; private set; }
|
||||||
|
|
||||||
@@ -152,31 +194,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
var reader = ReadLoopAsync(linked.Token);
|
var reader = ReadLoopAsync(linked.Token);
|
||||||
var flusher = FlushLoopAsync(linked.Token);
|
var flusher = FlushLoopAsync(linked.Token);
|
||||||
|
|
||||||
TerminalSessionOutcome outcome;
|
var outcome = await ReadUntilItEndsAsync(reader).ConfigureAwait(false);
|
||||||
try
|
|
||||||
{
|
|
||||||
await reader.ConfigureAwait(false);
|
|
||||||
|
|
||||||
// The read loop finished of its own accord, which means the shell is over. Which of the two ways
|
|
||||||
// it is over is not a question asked of the transport now — by now the server has very likely hung
|
|
||||||
// up behind a clean exit, and asking would call `exit` a dropped connection — but of what the shell
|
|
||||||
// latched at the moment it happened. See ISshShellSession.TransportFailure.
|
|
||||||
outcome = session.TransportFailure is { Length: > 0 } failure
|
|
||||||
? new TerminalSessionOutcome(SshSessionEnd.ConnectionLost, failure)
|
|
||||||
: new TerminalSessionOutcome(SshSessionEnd.RemoteClosed, "The remote closed the session.");
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
outcome = new TerminalSessionOutcome(SshSessionEnd.ClosedLocally, "The session was closed.");
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
// A read that threw rather than returning zero. Whatever it was, it was not this client asking —
|
|
||||||
// that arrives as the cancellation above — so it is the connection going out from under the read.
|
|
||||||
outcome = new TerminalSessionOutcome(
|
|
||||||
SshSessionEnd.ConnectionLost,
|
|
||||||
session.TransportFailure is { Length: > 0 } failure ? failure : exception.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before the drain and the last frame, so that anything reading this off a completed run — the
|
// Before the drain and the last frame, so that anything reading this off a completed run — the
|
||||||
// workspace's own announcement — cannot see the default.
|
// workspace's own announcement — cannot see the default.
|
||||||
@@ -185,7 +203,17 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
// Stop the flusher, but only after draining what the reader already produced — the last thing
|
// Stop the flusher, but only after draining what the reader already produced — the last thing
|
||||||
// a remote writes is often the most important, and dropping it makes a clean exit look like a
|
// a remote writes is often the most important, and dropping it makes a clean exit look like a
|
||||||
// crash.
|
// crash.
|
||||||
pending.Writer.TryComplete();
|
backlog.Complete();
|
||||||
|
|
||||||
|
// ◆ AND RELEASE THE FLUSHER IF IT IS PARKED ON CREDIT, WHICH IS NOT THE SAME SIGNAL. Completing
|
||||||
|
// the backlog wakes anything waiting for output; it says nothing to a flush loop that has output
|
||||||
|
// and is waiting for the renderer to make room for it. Against a renderer that has stopped
|
||||||
|
// acknowledging — the page's process killed, the window gone — that acknowledgement never comes,
|
||||||
|
// and without this the await below would never return: the shell would have ended, the tab would
|
||||||
|
// never be told, and the session would never be torn down. Resetting rather than waiting is
|
||||||
|
// right because the window describes a renderer's remaining room and there is nothing left to
|
||||||
|
// protect it from: what follows is one backlog's worth of tail and then the session is over.
|
||||||
|
Credits.Reset();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -203,6 +231,42 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waits for the read loop to finish, and says which of the three ways it did.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Split out of <see cref="RunAsync"/> for length rather than for reuse, and it is the half of that
|
||||||
|
/// method with no side effects: three branches, one answer, nothing touched.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="reader">The read loop's task.</param>
|
||||||
|
private async Task<TerminalSessionOutcome> ReadUntilItEndsAsync(Task reader)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await reader.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// The read loop finished of its own accord, which means the shell is over. Which of the two ways
|
||||||
|
// it is over is not a question asked of the transport now — by now the server has very likely hung
|
||||||
|
// up behind a clean exit, and asking would call `exit` a dropped connection — but of what the shell
|
||||||
|
// latched at the moment it happened. See ISshShellSession.TransportFailure.
|
||||||
|
return session.TransportFailure is { Length: > 0 } failure
|
||||||
|
? new TerminalSessionOutcome(SshSessionEnd.ConnectionLost, failure)
|
||||||
|
: new TerminalSessionOutcome(SshSessionEnd.RemoteClosed, "The remote closed the session.");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return new TerminalSessionOutcome(SshSessionEnd.ClosedLocally, "The session was closed.");
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// A read that threw rather than returning zero. Whatever it was, it was not this client asking —
|
||||||
|
// that arrives as the cancellation above — so it is the connection going out from under the read.
|
||||||
|
return new TerminalSessionOutcome(
|
||||||
|
SshSessionEnd.ConnectionLost,
|
||||||
|
session.TransportFailure is { Length: > 0 } failure ? failure : exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Forwards keystrokes to the remote.</summary>
|
/// <summary>Forwards keystrokes to the remote.</summary>
|
||||||
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
||||||
session.WriteAsync(data, cancellationToken);
|
session.WriteAsync(data, cancellationToken);
|
||||||
@@ -227,44 +291,37 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
// Unblocks anything waiting on credit that will now never be acknowledged.
|
// Unblocks anything waiting on credit that will now never be acknowledged.
|
||||||
Credits.Reset();
|
Credits.Reset();
|
||||||
|
|
||||||
pending.Writer.TryComplete();
|
backlog.Complete();
|
||||||
lifetime.Dispose();
|
lifetime.Dispose();
|
||||||
|
|
||||||
await session.DisposeAsync().ConfigureAwait(false);
|
await session.DisposeAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// No credit is consulted here, and the class remark says at length why: the remote is not waiting on
|
||||||
|
/// this loop, so pausing it buys nothing and costs a buffer inside SSH.NET that grows without bound.
|
||||||
|
/// What this loop owes is to keep that buffer empty, which means reading as fast as the channel
|
||||||
|
/// delivers and putting the result somewhere with a ceiling.
|
||||||
|
/// </remarks>
|
||||||
private async Task ReadLoopAsync(CancellationToken cancellationToken)
|
private async Task ReadLoopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var buffer = ArrayPool<byte>.Shared.Rent(options.ReadBufferBytes);
|
var buffer = ArrayPool<byte>.Shared.Rent(options.ReadBufferBytes);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (true)
|
||||||
{
|
{
|
||||||
await Credits.WaitForCreditAsync(cancellationToken).ConfigureAwait(false);
|
// Thrown rather than tested in the loop condition, and the difference is the whole of how
|
||||||
|
// this session's ending is described. RunAsync reads a read loop that RETURNS as
|
||||||
|
// the remote having closed the channel, and one that throws OperationCanceledException as
|
||||||
|
// this client having ended it. A condition on the token would leave by the first door for
|
||||||
|
// a reason that belongs to the second, and a tab the user closed would report itself as a
|
||||||
|
// shell that exited on its own.
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var granted = Credits.TryReserve(options.ReadBufferBytes);
|
var read = await session
|
||||||
if (granted == 0)
|
.ReadAsync(buffer.AsMemory(0, options.ReadBufferBytes), cancellationToken)
|
||||||
{
|
.ConfigureAwait(false);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
int read;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
read = await session
|
|
||||||
.ReadAsync(buffer.AsMemory(0, granted), cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
Credits.Return(granted);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hand back what was reserved but not used, so a short read does not permanently
|
|
||||||
// shrink the window.
|
|
||||||
Credits.Return(granted - read);
|
|
||||||
|
|
||||||
if (read == 0)
|
if (read == 0)
|
||||||
{
|
{
|
||||||
@@ -272,7 +329,10 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
BytesRead += read;
|
BytesRead += read;
|
||||||
await pending.Writer.WriteAsync(buffer[..read], cancellationToken).ConfigureAwait(false);
|
|
||||||
|
// 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.
|
||||||
|
backlog.Append(buffer[..read]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -283,65 +343,160 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
|
|
||||||
private async Task FlushLoopAsync(CancellationToken cancellationToken)
|
private async Task FlushLoopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var segments = new List<byte[]>();
|
while (await backlog.WaitForOutputAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
|
||||||
while (await pending.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
|
||||||
{
|
{
|
||||||
if (!pending.Reader.TryRead(out var first))
|
if (backlog.IsCompleted)
|
||||||
{
|
{
|
||||||
|
// The reader has finished and what is left is the tail, which goes out regardless of
|
||||||
|
// credit. Leaving by this door rather than going round again is load-bearing: with the
|
||||||
|
// backlog complete, SendWhatCreditAllowsAsync declines to wait for an acknowledgement
|
||||||
|
// that may never come, so a renderer that has stopped would put this loop into a spin —
|
||||||
|
// output to send, no credit to send it with, and a wait for more output that returns at
|
||||||
|
// once because there is some.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coalesce for one frame's worth of time before the first send, so a burst arriving at an idle
|
||||||
|
// terminal becomes a single write. Under sustained output the loop below never comes back
|
||||||
|
// here, and does not need to: credit is what paces it then, and a frame is as large as the
|
||||||
|
// renderer has room for.
|
||||||
|
await Task.Delay(options.FlushInterval, clock, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await SendWhatCreditAllowsAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await DrainAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sends until the backlog is empty or the renderer has no room left.</summary>
|
||||||
|
private async Task SendWhatCreditAllowsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var wanted = Math.Min(backlog.Buffered, MaximumFrameBytes);
|
||||||
|
if (wanted == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var granted = Credits.TryReserve(wanted);
|
||||||
|
|
||||||
|
if (granted == 0)
|
||||||
|
{
|
||||||
|
// The renderer is behind. Nothing more goes out until it acknowledges, and meanwhile the
|
||||||
|
// backlog holds what it can and drops its oldest for what it cannot — which is the whole
|
||||||
|
// of how this session's memory stays bounded.
|
||||||
|
if (backlog.IsCompleted)
|
||||||
|
{
|
||||||
|
// Except at the end, where waiting on an acknowledgement that may never come would
|
||||||
|
// hang the session's teardown behind a renderer that has already gone.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Credits.WaitForCreditAsync(cancellationToken).ConfigureAwait(false);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
segments.Clear();
|
var sent = await SendFromBacklogAsync(granted, cancellationToken).ConfigureAwait(false);
|
||||||
segments.Add(first);
|
|
||||||
|
|
||||||
// Coalesce for one frame's worth of time, then send everything at once. Whatever the
|
if (granted > sent)
|
||||||
// remote produced in that window becomes a single write to the terminal.
|
|
||||||
await Task.Delay(options.FlushInterval, clock, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
while (pending.Reader.TryRead(out var more))
|
|
||||||
{
|
{
|
||||||
segments.Add(more);
|
// Whatever was reserved and not used, so a backlog that shrank between the reservation
|
||||||
|
// and the take does not permanently narrow the window. Guarded rather than called with a
|
||||||
|
// zero, because returning credit wakes every waiter and replaces the signal they were
|
||||||
|
// waiting on, and this is the hot path.
|
||||||
|
Credits.Return(granted - sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
await SendOutputAsync(segments, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The channel completed. Anything the reader wrote before finishing still has to go out.
|
|
||||||
segments.Clear();
|
|
||||||
while (pending.Reader.TryRead(out var trailing))
|
|
||||||
{
|
|
||||||
segments.Add(trailing);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segments.Count > 0)
|
|
||||||
{
|
|
||||||
await SendOutputAsync(segments, CancellationToken.None).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask SendOutputAsync(List<byte[]> segments, CancellationToken cancellationToken)
|
/// <summary>
|
||||||
|
/// Puts the last of the output out, regardless of credit.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The session is over by the time this runs, so there is no renderer left to protect from a flood —
|
||||||
|
/// and what remains is bounded by the backlog's own capacity rather than by anything the remote can
|
||||||
|
/// still do. Waiting on credit here instead would mean a shell that exited against a renderer which
|
||||||
|
/// had gone never finished tearing down.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task DrainAsync()
|
||||||
{
|
{
|
||||||
var total = 0;
|
while (await SendFromBacklogAsync(MaximumFrameBytes, CancellationToken.None)
|
||||||
foreach (var segment in segments)
|
.ConfigureAwait(false) > 0)
|
||||||
{
|
{
|
||||||
total += segment.Length;
|
// Until it is empty.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves up to <paramref name="maxBytes"/> out of the backlog and onto the wire.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>How many bytes of output were sent, which is zero once the backlog is empty.</returns>
|
||||||
|
private async ValueTask<int> SendFromBacklogAsync(int maxBytes, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Taken straight into the frame, header space and all. A second buffer here would be an
|
||||||
|
// allocation and a copy of every byte the terminal ever shows.
|
||||||
|
var frame = new byte[TerminalFrame.HeaderLength + maxBytes];
|
||||||
|
var taken = backlog.TakeInto(frame.AsSpan(TerminalFrame.HeaderLength), out var dropped);
|
||||||
|
|
||||||
|
if (dropped > 0)
|
||||||
|
{
|
||||||
|
// Sent immediately before the bytes it explains, and under its own opcode — see
|
||||||
|
// TerminalServerOpcode.OutputDropped for why this is not simply spliced into the output.
|
||||||
|
await SendAsync(TerminalServerOpcode.OutputDropped, DroppedNotice(dropped), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taken == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var frame = new byte[TerminalFrame.HeaderLength + total];
|
|
||||||
TerminalFrame.Write(frame, (byte)TerminalServerOpcode.Output, sessionId, default);
|
TerminalFrame.Write(frame, (byte)TerminalServerOpcode.Output, sessionId, default);
|
||||||
|
|
||||||
var offset = TerminalFrame.HeaderLength;
|
|
||||||
foreach (var segment in segments)
|
|
||||||
{
|
|
||||||
segment.CopyTo(frame, offset);
|
|
||||||
offset += segment.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
FramesSent++;
|
FramesSent++;
|
||||||
await transport.SendAsync(frame, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
await transport
|
||||||
|
.SendAsync(frame.AsMemory(0, TerminalFrame.HeaderLength + taken), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return taken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The line a user sees where output was dropped.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// It opens with a reset rather than the colour, and that is the load-bearing part: the bytes were cut
|
||||||
|
/// at an arbitrary offset, so whatever escape sequence the remote was in the middle of has been left
|
||||||
|
/// half-said. Resetting first means the notice reads as a notice and the output after it starts from a
|
||||||
|
/// known state, instead of inheriting a colour or a mode from a sequence nobody finished.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Dim grey, matching the renderer's own banners for a session that closed and a view that
|
||||||
|
/// reconnected — this is the same kind of thing, a sentence from the application rather than from the
|
||||||
|
/// remote, and it should not read like part of the transcript.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static byte[] DroppedNotice(long dropped) =>
|
||||||
|
Encoding.UTF8.GetBytes(string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"\u001b[0m\r\n\u001b[38;5;244m── {Sized(dropped)} of output was dropped; "
|
||||||
|
+ $"the terminal could not keep up ──\u001b[0m\r\n"));
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// One decimal place from a megabyte up, because the number is an order of magnitude rather than an
|
||||||
|
/// accounting: what a person needs from it is whether they lost a screenful or a session's worth.
|
||||||
|
/// </remarks>
|
||||||
|
private static string Sized(long bytes) => bytes switch
|
||||||
|
{
|
||||||
|
< 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes} bytes"),
|
||||||
|
< 1024 * 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes / 1024.0:0.#} KB"),
|
||||||
|
< 1024L * 1024 * 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes / (1024.0 * 1024):0.#} MB"),
|
||||||
|
_ => string.Create(CultureInfo.CurrentCulture, $"{bytes / (1024.0 * 1024 * 1024):0.#} GB"),
|
||||||
|
};
|
||||||
|
|
||||||
private async ValueTask SendAsync(
|
private async ValueTask SendAsync(
|
||||||
TerminalServerOpcode opcode,
|
TerminalServerOpcode opcode,
|
||||||
ReadOnlyMemory<byte> payload,
|
ReadOnlyMemory<byte> payload,
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
|
|||||||
public int Step { get; } = step;
|
public int Step { get; } = step;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The renderer handing over text for the system clipboard.</summary>
|
||||||
|
/// <param name="text">
|
||||||
|
/// The selection, exactly as the page had it. Never empty — the transport drops an empty copy rather than
|
||||||
|
/// letting a stray gesture wipe the machine's clipboard.
|
||||||
|
/// </param>
|
||||||
|
public sealed class TerminalClipboardCopyEventArgs(string text) : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>What to put on the clipboard.</summary>
|
||||||
|
public string Text { get; } = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The renderer asking for the system clipboard's text.</summary>
|
||||||
|
/// <param name="sessionId">
|
||||||
|
/// The pane the pointer was over, which is where the text is to land. The page sends it rather than leaving
|
||||||
|
/// the host to infer it from whichever tab it believes is selected.
|
||||||
|
/// </param>
|
||||||
|
public sealed class TerminalClipboardPasteEventArgs(uint sessionId) : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>The terminal to paste into.</summary>
|
||||||
|
public uint SessionId { get; } = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Owns the loopback data plane and every live terminal session.
|
/// Owns the loopback data plane and every live terminal session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -148,6 +170,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
// what remembers it between launches.
|
// what remembers it between launches.
|
||||||
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
|
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
|
||||||
|
|
||||||
|
// Forwarded for the same reason and decided no more than the size is. What a clipboard is and how it
|
||||||
|
// is reached is a property of the platform the shell is running on, and this object has never seen a
|
||||||
|
// visual.
|
||||||
|
dataPlane.ClipboardCopyRequested += (_, e) => ClipboardCopyRequested?.Invoke(this, e);
|
||||||
|
dataPlane.ClipboardPasteRequested += (_, e) => ClipboardPasteRequested?.Invoke(this, e);
|
||||||
|
|
||||||
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
|
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
|
||||||
// handshake handling, and has no business making that wait on however long a replay takes. See
|
// handshake handling, and has no business making that wait on however long a replay takes. See
|
||||||
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
|
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
|
||||||
@@ -181,11 +209,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes
|
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes the
|
||||||
/// the channel on its own — <see cref="RunSessionAsync"/> only drops the renderer registration — so
|
/// channel on its own — <see cref="ReleaseAsync"/> gives back what that session was holding but leaves
|
||||||
/// a session whose shell exited half an hour ago is still in the dictionary. A completed
|
/// the entry, so the tab can go on saying it ended — and a session whose shell exited half an hour ago
|
||||||
/// <c>Run</c> task is what "the shell is gone" actually looks like: the pump's loops have finished
|
/// is therefore still in the dictionary. A completed <c>Run</c> task is what "the shell is gone"
|
||||||
/// and it has already sent <c>SessionClosed</c> to the renderer.
|
/// actually looks like: the pump's loops have finished and it has already sent <c>SessionClosed</c> to
|
||||||
|
/// the renderer.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This exists because the shell shows the number on the unlock screen, and a lock screen that
|
/// This exists because the shell shows the number on the unlock screen, and a lock screen that
|
||||||
@@ -313,6 +342,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
||||||
|
|
||||||
|
/// <summary>Raised when a right-click over a selection asks for it to be copied.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The gesture can only be heard by the page, for the reason <see cref="FontSizeStepRequested"/> gives,
|
||||||
|
/// and the selection can only be <em>read</em> there: xterm draws it into a canvas. So the text arrives
|
||||||
|
/// here, and what a clipboard is on this platform is the shell's to know. Raised on the data plane's
|
||||||
|
/// receive loop; marshal before touching one.
|
||||||
|
/// </remarks>
|
||||||
|
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
|
||||||
|
|
||||||
|
/// <summary>Raised when a right-click with nothing selected asks for the clipboard to be pasted.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Answered with <see cref="SendClipboardAsync"/>, which is the other half: this event carries no text
|
||||||
|
/// because the page has none to give — reading the clipboard is exactly what it cannot do. Raised on the
|
||||||
|
/// data plane's receive loop; marshal before touching a view model, and before touching a clipboard,
|
||||||
|
/// which is a UI-thread object on more than one platform.
|
||||||
|
/// </remarks>
|
||||||
|
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
|
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -359,6 +406,55 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
TerminalFrame.CreateFontSizePayload(pixels)),
|
TerminalFrame.CreateFontSizePayload(pixels)),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hands the system clipboard's text to one pane, to be pasted into it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The answer to <see cref="ClipboardPasteRequested"/>, and the only thing that sends this frame. Always
|
||||||
|
/// bracketed and never followed by an Enter — see <see cref="TerminalServerOpcode.Clipboard"/> for why a
|
||||||
|
/// clipboard paste is not the same act as inserting a snippet, and why the two do not share an opcode.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Refused for a dead session, exactly as <see cref="PasteAsync"/> is refused and for the weaker of its
|
||||||
|
/// two reasons: nothing visible would happen either way, because a paste is keystrokes rather than
|
||||||
|
/// output and there is no shell left to echo them. Checked anyway so that the answer is honest, and so
|
||||||
|
/// that a caller which grows a message one day has something to hang it on.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// An empty clipboard is the caller's to notice. Sending nothing is not the same as sending an empty
|
||||||
|
/// string — the page would bracket it and the remote would see an empty paste — and "there was nothing
|
||||||
|
/// to paste" is a fact about the machine rather than about this terminal.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="sessionId">The terminal to paste into.</param>
|
||||||
|
/// <param name="text">The clipboard's text. Sent verbatim.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation.</param>
|
||||||
|
/// <returns><see langword="false"/> when that session's shell is not running.</returns>
|
||||||
|
public async Task<bool> SendClipboardAsync(
|
||||||
|
uint sessionId,
|
||||||
|
string text,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(text);
|
||||||
|
|
||||||
|
if (!IsSessionLive(sessionId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dataPlane
|
||||||
|
.SendAsync(
|
||||||
|
TerminalFrame.Create(
|
||||||
|
(byte)TerminalServerOpcode.Clipboard,
|
||||||
|
sessionId,
|
||||||
|
Encoding.UTF8.GetBytes(text)),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Waits until the renderer page has attached its socket.
|
/// Waits until the renderer page has attached its socket.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -547,6 +643,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
/// A session id this workspace does not know is ignored rather than throwing. The caller is a tab
|
/// A session id this workspace does not know is ignored rather than throwing. The caller is a tab
|
||||||
/// strip, and a tab that closed while a key was in flight is ordinary rather than exceptional.
|
/// strip, and a tab that closed while a key was in flight is ordinary rather than exceptional.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A session whose shell has <em>ended</em> is ignored the same way, and has to be: the tab is still on
|
||||||
|
/// screen and still selectable, so an accessory key pressed at one is as ordinary as a key pressed at a
|
||||||
|
/// tab that has closed. It is also the one of the two that would throw rather than fail quietly, since
|
||||||
|
/// <see cref="ReleaseAsync"/> disposes the shell channel the moment the remote hangs up. Liveness
|
||||||
|
/// rather than mere presence, which is the same question <see cref="IsSessionLive"/> answers.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="sessionId">The terminal to type into.</param>
|
/// <param name="sessionId">The terminal to type into.</param>
|
||||||
/// <param name="data">Raw bytes, already encoded as the remote expects them.</param>
|
/// <param name="data">Raw bytes, already encoded as the remote expects them.</param>
|
||||||
@@ -560,7 +663,9 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
|
|
||||||
lock (sessionGate)
|
lock (sessionGate)
|
||||||
{
|
{
|
||||||
pump = sessions.TryGetValue(sessionId, out var session) ? session.Pump : null;
|
pump = sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted
|
||||||
|
? session.Pump
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pump is null ? ValueTask.CompletedTask : pump.WriteInputAsync(data, cancellationToken);
|
return pump is null ? ValueTask.CompletedTask : pump.WriteInputAsync(data, cancellationToken);
|
||||||
@@ -753,14 +858,79 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
// tab, the remote hanging up, and the process shutting down. Every one of them arrives here as
|
// tab, the remote hanging up, and the process shutting down. Every one of them arrives here as
|
||||||
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
|
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
|
||||||
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
|
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
|
||||||
// the application is closing.
|
// the application is closing, and everything inside a finally is time the run task has not
|
||||||
|
// completed yet, which is time LiveSessionCount still counts this session as running.
|
||||||
//
|
//
|
||||||
// SessionEnded is deliberately NOT raised from here, and it used to be — see
|
// Neither SessionEnded nor the release of what the session held is done from here, and both
|
||||||
// AnnounceEndedAsync for what was wrong with that.
|
// used to be candidates for it — see AnnounceEndedAsync, which is where they ended up and why.
|
||||||
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gives back everything a session that ended on its own was still holding.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Nothing used to do this, and a shell that exited went on costing what a running one costs.</b>
|
||||||
|
/// The entry stayed in <see cref="sessions"/> until the user closed the tab, and with it an undisposed
|
||||||
|
/// <see cref="ISshConnection"/> — an authenticated socket to the remote, its cipher state, and a
|
||||||
|
/// keep-alive still putting a packet on the wire every thirty seconds — plus the pump and, underneath
|
||||||
|
/// it, SSH.NET's read buffer at whatever size the session's heaviest moment had grown it to. A tab
|
||||||
|
/// somebody typed <c>exit</c> into an hour ago and left open was indistinguishable from a live one in
|
||||||
|
/// everything except what it drew, and a working day of those is the memory a user actually notices.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The dictionary entry stays even so, and that is not an oversight.</b>
|
||||||
|
/// <see cref="LiveSessionCount"/>, <see cref="IsSessionLive"/> and
|
||||||
|
/// <see cref="AnnounceEndedAsync"/>'s containment check are all written against it — a session that
|
||||||
|
/// ended still has to be able to say so, and a tab that shows ENDED and offers a reconnect is reading
|
||||||
|
/// exactly that. What goes is everything the entry was holding that costs anything; what is left is
|
||||||
|
/// three references and a completed task.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Skipped entirely when the entry has already been removed, which is precisely the deliberate path:
|
||||||
|
/// <see cref="CloseSessionAsync"/> takes the entry out before it disposes anything, so the two can
|
||||||
|
/// never both be tearing the same session down. That is also what makes this a no-op during
|
||||||
|
/// <see cref="DisposeAsync"/>, which closes every session by that route before anything here could run.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Every failure is swallowed. The caller is a fire-and-forget continuation with nobody to hand a fault
|
||||||
|
/// to, and the shell it would be about is already gone.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task ReleaseAsync(uint sessionId)
|
||||||
|
{
|
||||||
|
LiveSession? session;
|
||||||
|
|
||||||
|
lock (sessionGate)
|
||||||
|
{
|
||||||
|
if (!sessions.TryGetValue(sessionId, out session))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// The connection goes even if the pump's disposal throws, for the reason CloseSessionAsync
|
||||||
|
// gives at the same pair of calls: an undisposed ISshConnection is a live authenticated
|
||||||
|
// channel this process has forgotten about.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await session.Pump.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await session.Connection.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// Best-effort. The shell is already gone; nothing a caller could do with this.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
||||||
/// <param name="sessionId">The session, as the renderer knows it.</param>
|
/// <param name="sessionId">The session, as the renderer knows it.</param>
|
||||||
/// <param name="pump">
|
/// <param name="pump">
|
||||||
@@ -803,12 +973,25 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
announce = sessions.ContainsKey(sessionId);
|
announce = sessions.ContainsKey(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (announce)
|
if (!announce)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
// Read after the await, which is the only point at which the pump has settled it — see
|
// Read after the await, which is the only point at which the pump has settled it — see
|
||||||
// TerminalSessionPump.Outcome.
|
// TerminalSessionPump.Outcome.
|
||||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, pump.Outcome));
|
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, pump.Outcome));
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// After the announcement rather than before it, and in a finally so a handler that throws
|
||||||
|
// cannot leave a connection open. Tearing down an SSH connection is a socket closing and a
|
||||||
|
// library shutting down its listener; doing that first would put it between the shell ending
|
||||||
|
// and the tab being told, which is latency on the one thing the user is watching for.
|
||||||
|
await ReleaseAsync(sessionId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The address as dialled, for the log.</summary>
|
/// <summary>The address as dialled, for the log.</summary>
|
||||||
|
|||||||
@@ -250,12 +250,19 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
/// way down rather than acted on as it bubbles.
|
/// way down rather than acted on as it bubbles.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// ◆ <b>And the set the first Ctrl-click starts has the lit card in it.</b> The board always has a
|
||||||
|
/// selection — a fresh unlock lights the first host so CONNECT has something under it — so "one card
|
||||||
|
/// lit, nothing ticked" is the state this screen opens in. A Ctrl-click there used to produce a set of
|
||||||
|
/// one, leaving the machine the user was looking at out of the count and out of everything Delete, Move
|
||||||
|
/// and Change group then acted on. See <c>VaultViewModel.ExtendHostChoice</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
|
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
|
||||||
/// card is in play and every command on this screen is about the same host.
|
/// card is in play and every command on this screen is about the same host.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
|
public async Task CtrlClickingCardsTicksThemAndTheLitCardWithoutMovingTheSelection()
|
||||||
{
|
{
|
||||||
await OnTheBoardAsync((screen, window) =>
|
await OnTheBoardAsync((screen, window) =>
|
||||||
{
|
{
|
||||||
@@ -267,16 +274,19 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
|
|
||||||
Click(CardFor(screen, second), window, RawInputModifiers.Control);
|
Click(CardFor(screen, second), window, RawInputModifiers.Control);
|
||||||
|
|
||||||
vault.ChosenHostCount.ShouldBe(1);
|
vault.ChosenHostCount.ShouldBe(2, "the card the board had lit is in the set the Ctrl-click began");
|
||||||
|
first.IsChosen.ShouldBeTrue("which is the half that used to be left out of it");
|
||||||
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
|
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
|
||||||
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
|
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
|
||||||
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise");
|
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise");
|
||||||
|
|
||||||
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
||||||
vault.ChosenHostCount.ShouldBe(2);
|
|
||||||
|
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
|
||||||
|
first.IsChosen.ShouldBeFalse("the lit card is not folded back in over a set that already exists");
|
||||||
|
|
||||||
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
||||||
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
|
vault.ChosenHostCount.ShouldBe(2);
|
||||||
|
|
||||||
Click(CardFor(screen, first), window);
|
Click(CardFor(screen, first), window);
|
||||||
|
|
||||||
@@ -387,6 +397,12 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
|
|
||||||
var tickedCard = CardFor(screen, ticked);
|
var tickedCard = CardFor(screen, ticked);
|
||||||
|
|
||||||
|
// Lit first, and it matters: a Ctrl-click folds the lit card into the set it starts — see
|
||||||
|
// ExtendHostChoice — so ticking one card while another is lit would leave no card outside the
|
||||||
|
// set for the second half of this test to right-click.
|
||||||
|
vault.SelectedHost = ticked;
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
Click(tickedCard, window, RawInputModifiers.Control);
|
Click(tickedCard, window, RawInputModifiers.Control);
|
||||||
|
|
||||||
RightClick(tickedCard, window);
|
RightClick(tickedCard, window);
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ internal static class LayoutHarness
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal const double HostDrawerWidth = 320;
|
internal const double HostDrawerWidth = 320;
|
||||||
|
|
||||||
|
/// <summary>The splash window's fixed width, from <c>SplashWindow.axaml</c>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Its height is deliberately absent from this list, because the window has none to state: it is
|
||||||
|
/// <c>SizeToContent="Height"</c> so that a failure message of any length is readable rather than
|
||||||
|
/// clipped. <see cref="SplashMinimumHeight"/> is the floor under the ordinary one-line case, and
|
||||||
|
/// <c>SplashCardTests</c> pins all three against the XAML.
|
||||||
|
/// </remarks>
|
||||||
|
internal const double SplashWidth = 420;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="SplashWidth" />
|
||||||
|
internal const double SplashMinimumHeight = 160;
|
||||||
|
|
||||||
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
||||||
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
||||||
internal const double NavRailWidth = 255;
|
internal const double NavRailWidth = 255;
|
||||||
|
|||||||
@@ -210,32 +210,38 @@ public sealed class NavRailTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Settings, Vaults and Preferences each enter settings mode on the page they promise, and shut the
|
/// Settings enters settings mode on its landing page, and shuts the popover behind it.
|
||||||
/// popover behind them.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// v5c gave this menu three navigation rows — Settings, Vaults and Preferences — because settings mode
|
||||||
/// v5c: these three used to land on a bare <c>ShellScreen</c> — Settings and Preferences on the very
|
/// had just been built out of what were two bare <c>ShellScreen</c>s, and "Settings" and "Preferences"
|
||||||
/// same one, since the mock's own Settings area did not exist yet. Now that it does, each opens the
|
/// had been the same click until then. All three opened the same mode and differed only in which page
|
||||||
/// settings mode on its own page — see <see cref="MainWindowViewModel.EnterSettings"/> — and "Settings"
|
/// they landed on, which made two of them deep links into a place the third one opens, past a
|
||||||
/// and "Preferences" are no longer the same click.
|
/// <c>SettingsNav</c> that lists every page anyway. One row now; see <c>NavRail.axaml</c>'s own remark.
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>SettingsPage</c>
|
|
||||||
/// is <c>internal</c>, and a public theory method may not carry an internal type in its signature.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public Task ThePopoversSettingsRow_EntersSettingsOnGeneralAndClosesThePopover() =>
|
public Task ThePopoversSettingsRow_EntersSettingsOnGeneralAndClosesThePopover() =>
|
||||||
APopoverRowLandsOnAsync("Settings", SettingsPage.General);
|
APopoverRowLandsOnAsync("Settings", SettingsPage.General);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The two rows that were folded into it are gone from the menu rather than merely unwired.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Worth a test of its own because the failure it guards is silent: a row left in the markup and
|
||||||
|
/// stripped of its handler is a menu entry that does nothing when pressed, which reads as a broken
|
||||||
|
/// application rather than as a tidied menu. Both destinations are still reachable — one click deeper,
|
||||||
|
/// from <c>SettingsNav</c> — which is the whole argument for removing them from here.
|
||||||
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public Task ThePopoversVaultsRow_EntersSettingsOnVaultsAndClosesThePopover() =>
|
public Task ThePopoverNoLongerOffersVaultsOrPreferencesOfItsOwn() =>
|
||||||
APopoverRowLandsOnAsync("Vaults", SettingsPage.Vaults);
|
OnTheRailAsync((rail, window) =>
|
||||||
|
{
|
||||||
|
Click(UserChip(rail), window);
|
||||||
|
|
||||||
[Fact]
|
PopoverRows(window).ShouldNotContain("Vaults");
|
||||||
public Task ThePopoversPreferencesRow_EntersSettingsOnPreferencesAndClosesThePopover() =>
|
PopoverRows(window).ShouldNotContain("Preferences");
|
||||||
APopoverRowLandsOnAsync("Preferences", SettingsPage.Preferences);
|
PopoverRows(window).ShouldContain("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
private Task APopoverRowLandsOnAsync(string label, SettingsPage target) =>
|
private Task APopoverRowLandsOnAsync(string label, SettingsPage target) =>
|
||||||
OnTheRailAsync((rail, window) =>
|
OnTheRailAsync((rail, window) =>
|
||||||
@@ -369,6 +375,24 @@ public sealed class NavRailTests : IAsyncLifetime
|
|||||||
.OfType<TextBlock>()
|
.OfType<TextBlock>()
|
||||||
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
|
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every word the open popover offers as a place to go.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The vault switches are excluded by their data context rather than by their label, because a vault is
|
||||||
|
/// free to be called anything — including "Vaults" — and a test asserting that a menu entry is absent
|
||||||
|
/// must not be satisfiable or defeatable by what somebody named a vault.
|
||||||
|
/// </remarks>
|
||||||
|
private static IReadOnlyList<string> PopoverRows(Visual window) =>
|
||||||
|
[.. window.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.Where(button => button.Classes.Contains("poprow")
|
||||||
|
&& button.DataContext is not VaultToggleViewModel)
|
||||||
|
.SelectMany(button => button.GetVisualDescendants().OfType<TextBlock>())
|
||||||
|
.Select(text => text.Text)
|
||||||
|
.Where(text => !string.IsNullOrEmpty(text))
|
||||||
|
.Select(text => text!)];
|
||||||
|
|
||||||
/// <summary>Whether a vault row's magenta check square is drawn, for whether it is currently shown.</summary>
|
/// <summary>Whether a vault row's magenta check square is drawn, for whether it is currently shown.</summary>
|
||||||
private static bool HasVisibleCheck(Button row) =>
|
private static bool HasVisibleCheck(Button row) =>
|
||||||
row.GetVisualDescendants().OfType<Border>().Any(
|
row.GetVisualDescendants().OfType<Border>().Any(
|
||||||
|
|||||||
@@ -942,6 +942,49 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab);
|
await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The card is the one place the shell says what it is dialling <em>before</em> anything answers, and on a
|
||||||
|
/// refusal it is the only copy of that on screen — so both lines are there to be taken away, and a
|
||||||
|
/// <c>TextBlock</c> in Avalonia is a drawing of a string rather than a string. Same change, same argument,
|
||||||
|
/// as the session column's own address; see <c>SessionSidebarTests</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asserted on values long enough to be trimmed, which is the half that could quietly break: an ellipsis
|
||||||
|
/// is a drawing and not a shorter string, so select-all has to reach the whole name and the whole address
|
||||||
|
/// rather than the part that happened to fit inside the card's 460 pixels. Values that fit would pass
|
||||||
|
/// whether that were true or not.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheConnectingCardsNameAndAddressSelectAndCopyWhole()
|
||||||
|
{
|
||||||
|
const string label = "customer-production-database-01.eu-west-1.internal.example.company";
|
||||||
|
const string address = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022";
|
||||||
|
|
||||||
|
await OnTheConnectingCardAsync(
|
||||||
|
card =>
|
||||||
|
{
|
||||||
|
// Found by their text rather than by position: the order is the visual tree's, and a third
|
||||||
|
// selectable block is already in there — the refusal reason, hidden while this tab is only
|
||||||
|
// connecting. Single rather than First, so a duplicate would be a question rather than a
|
||||||
|
// silent pick.
|
||||||
|
var takeable = card.GetVisualDescendants().OfType<SelectableTextBlock>().ToList();
|
||||||
|
|
||||||
|
var name = takeable.Single(block => block.Text == label);
|
||||||
|
var endpoint = takeable.Single(block => block.Text == address);
|
||||||
|
|
||||||
|
name.SelectAll();
|
||||||
|
name.SelectedText.ShouldBe(label, "the ellipsis is a drawing, not a shorter string");
|
||||||
|
name.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
|
||||||
|
|
||||||
|
endpoint.SelectAll();
|
||||||
|
endpoint.SelectedText.ShouldBe(address, "the ellipsis is a drawing, not a shorter string");
|
||||||
|
endpoint.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
|
||||||
|
},
|
||||||
|
new TerminalTabViewModel(label, address));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- The logs screen ----
|
// ---- The logs screen ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -2043,6 +2086,40 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
},
|
},
|
||||||
Token);
|
Token);
|
||||||
|
|
||||||
|
/// <summary>Lays the connecting card out and hands it over, rather than measuring it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same rectangle <see cref="MeasureConnectingAsync"/> uses and for the same reasons — see its
|
||||||
|
/// remark. Separate because the claim is about a control's own state rather than about where anything was
|
||||||
|
/// arranged, and threading an assertion over the card through a method whose parameter is a fault list
|
||||||
|
/// would make both callers harder to read than two methods are.
|
||||||
|
/// </remarks>
|
||||||
|
private Task OnTheConnectingCardAsync(Action<ConnectingCard> body, TerminalTabViewModel tab) =>
|
||||||
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
shell.State = ShellState.Unlocked;
|
||||||
|
shell.Tabs.Clear();
|
||||||
|
shell.Tabs.Add(tab);
|
||||||
|
shell.SelectedTab = tab;
|
||||||
|
|
||||||
|
var card = new ConnectingCard { DataContext = shell };
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
card,
|
||||||
|
LayoutHarness.SessionScreenWidth(sidebarVisible: true),
|
||||||
|
LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body(card);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
|
||||||
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
|
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|||||||
@@ -416,6 +416,32 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
|
|||||||
await ScannedImportAsync());
|
await ScannedImportAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- The Hetzner wizard ----
|
||||||
|
//
|
||||||
|
// Measured against the same SettingsContentWidth/SettingsContentHeight budget, because it is drawn in
|
||||||
|
// the same content column over the same Preferences page — see MainWindowViewModel.IsHetznerImportOpen.
|
||||||
|
// Two shapes, because the two steps are different layouts rather than one with a section hidden: the
|
||||||
|
// token step is a card, and the choose step is a table with an options card above it and a footer below.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheHetznerWizardFitsOnTheStepItOpensOn()
|
||||||
|
{
|
||||||
|
await MeasureHetznerAsync(faults => faults.ShouldBeEmpty("the token step"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The shape with something to decide about, and the two rows that must not be pushed off the bottom are
|
||||||
|
/// the sentence saying no key comes with these hosts and the IMPORT button under it — the same pair
|
||||||
|
/// <see cref="TheImportScreenFitsWithHostsToChooseFromAndWarnings"/> is written about.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheHetznerWizardFitsWithServersToChooseFromAndWarnings()
|
||||||
|
{
|
||||||
|
await MeasureHetznerAsync(
|
||||||
|
faults => faults.ShouldBeEmpty("with a scanned project"),
|
||||||
|
await ScannedHetznerAsync());
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -538,4 +564,93 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
|
|||||||
Directory.Delete(directory, recursive: true);
|
Directory.Delete(directory, recursive: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="MeasureImportAsync" />
|
||||||
|
private Task MeasureHetznerAsync(
|
||||||
|
Action<IReadOnlyList<string>> assert,
|
||||||
|
HetznerImportViewModel? wizard = null) =>
|
||||||
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new HetznerImportScreen
|
||||||
|
{
|
||||||
|
DataContext = wizard ?? new HetznerImportViewModel(vault, new HetznerCloudClient()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
assert(LayoutHarness.Unreachable(window));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A wizard that has read a project, so the table has rows in it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Through the real client over a stubbed transport rather than by populating the rows directly, on the
|
||||||
|
/// reasoning <see cref="ScannedImportAsync"/> gives: a row carrying a warning is taller than one without,
|
||||||
|
/// and inventing the rows would measure a layout nothing generates. The fixture is chosen to produce all
|
||||||
|
/// three chip states plus a project-level warning — a plain server, one with no IPv4, and one the API
|
||||||
|
/// described without a name.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<HetznerImportViewModel> ScannedHetznerAsync()
|
||||||
|
{
|
||||||
|
var http = new HttpClient(new StubHetzner("""
|
||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "web-01",
|
||||||
|
"status": "running",
|
||||||
|
"public_net": { "ipv4": { "ip": "203.0.113.10" } },
|
||||||
|
"server_type": { "name": "cx22" },
|
||||||
|
"datacenter": { "location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" } },
|
||||||
|
"image": { "name": "ubuntu-24.04" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "edge-v6",
|
||||||
|
"status": "off",
|
||||||
|
"public_net": { "ipv6": { "ip": "2a01:4f8:1c17:d00::/64" } },
|
||||||
|
"server_type": { "name": "cax11" },
|
||||||
|
"datacenter": { "location": { "name": "hel1", "city": "Helsinki", "country": "FI" } }
|
||||||
|
},
|
||||||
|
{ "id": 3, "status": "initializing" }
|
||||||
|
],
|
||||||
|
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": null } }
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
var wizard = new HetznerImportViewModel(vault, new HetznerCloudClient(http))
|
||||||
|
{
|
||||||
|
Token = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||||||
|
};
|
||||||
|
|
||||||
|
await wizard.ScanCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
wizard.HasRows.ShouldBeTrue("the fixture has servers in it");
|
||||||
|
wizard.HasWarnings.ShouldBeTrue("the fixture has a server the API left unnamed");
|
||||||
|
|
||||||
|
return wizard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Answers anything asked of it with one page. There is no sequence to model here.</summary>
|
||||||
|
private sealed class StubHetzner(string body) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Layout.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The splash, and the one thing about it that can go wrong.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Three stacked elements in a fixed-width window is not a layout with much to get wrong — except in one
|
||||||
|
/// state. <c>SplashWindow.CouldNotStart</c> carries an exception's own message, whose length nothing in this
|
||||||
|
/// application decides, and that message is the whole reason that window is still on screen. Inside a fixed
|
||||||
|
/// height it would wrap and be clipped at exactly the wrong moment.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// So what is measured here is that the message grows the card, and that the window is declared in a way
|
||||||
|
/// that lets the growth through. <c>LayoutHarness.Unreachable</c> is no help: it reports interactive controls
|
||||||
|
/// arranged where they cannot be used, and the splash has none — which is why these two assert on measured
|
||||||
|
/// heights instead.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SplashCardTests
|
||||||
|
{
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
/// <summary>A message about as long as a real one gets: a path, a reason and a suggestion.</summary>
|
||||||
|
private const string LongFailure =
|
||||||
|
"The process cannot access the file 'C:\\Users\\somebody\\AppData\\Local\\DodoSSH\\cache.db' "
|
||||||
|
+ "because it is being used by another process, and this build cannot open a second copy of it.";
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Pins the two constants and the mechanism against the XAML, the way
|
||||||
|
/// <c>LayoutHarnessTests.TheHarnessMeasuresTheSizeTheWindowDeclares</c> pins the main window's minimum.
|
||||||
|
/// <c>SizeToContent</c> is the one that matters: a window that lost it would clip the message and this
|
||||||
|
/// suite's other test would go on passing, because the card would still be measuring the taller size it
|
||||||
|
/// was never given.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheSplashIsDeclaredTheWayTheHarnessMeasuresIt()
|
||||||
|
{
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var window = new SplashWindow();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
window.Width.ShouldBe(LayoutHarness.SplashWidth);
|
||||||
|
window.MinHeight.ShouldBe(LayoutHarness.SplashMinimumHeight);
|
||||||
|
window.SizeToContent.ShouldBe(
|
||||||
|
SizeToContent.Height,
|
||||||
|
"a fixed height would clip a long failure message");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The ordinary state has to fit the floor, or the window opens taller than its own minimum for the one
|
||||||
|
/// line it normally shows — which would make <see cref="LayoutHarness.SplashMinimumHeight"/> a number
|
||||||
|
/// that never applies to anything.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheStartingMessageFitsTheHeightTheSplashOpensAt()
|
||||||
|
{
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() => MessageHeight(card => { }).ShouldBeLessThanOrEqualTo(
|
||||||
|
LayoutHarness.SplashMinimumHeight,
|
||||||
|
"the splash opens at its minimum and the one line it starts with has to fit"),
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ The test with something behind it. A message that wraps to three lines has to make the card ask for
|
||||||
|
/// more room; if it did not, the text would be sitting under the bottom edge of the window and the only
|
||||||
|
/// thing the splash exists to carry at that point would be the part nobody could read.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ALongFailureMessageAsksForMoreRoomRatherThanBeingClipped()
|
||||||
|
{
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var starting = MessageHeight(card => { });
|
||||||
|
var failed = MessageHeight(card => card.CouldNotStart(LongFailure));
|
||||||
|
|
||||||
|
failed.ShouldBeGreaterThan(
|
||||||
|
starting,
|
||||||
|
"the message wraps, so the card has to want the height the window then gives it");
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What one card wants to be, at the splash's width, after the caller has had its say.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Hosted in a window before being measured, because the heading and the hint take their size and their
|
||||||
|
/// wrapping from <c>App.axaml</c> — a card measured detached would be measuring unstyled text. Measured
|
||||||
|
/// again afterwards against an unbounded height, which is what the real window's <c>SizeToContent</c>
|
||||||
|
/// gives it and what the host window's own fixed height would otherwise clamp away.
|
||||||
|
/// </remarks>
|
||||||
|
private static double MessageHeight(Action<SplashCard> say)
|
||||||
|
{
|
||||||
|
var card = new SplashCard();
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
card, LayoutHarness.SplashWidth, LayoutHarness.SplashMinimumHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
say(card);
|
||||||
|
|
||||||
|
card.Measure(new Avalonia.Size(LayoutHarness.SplashWidth, double.PositiveInfinity));
|
||||||
|
|
||||||
|
return card.DesiredSize.Height;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,63 @@ internal sealed partial class FakeRenderer : IAsyncDisposable
|
|||||||
return renderer;
|
return renderer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sends a client frame, as the page would.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Raw rather than through a helper per opcode: the two this exists for carry a session id and either
|
||||||
|
/// UTF-8 or nothing at all, and a method each would be three lines of ceremony around
|
||||||
|
/// <see cref="TerminalFrame.Create"/>.
|
||||||
|
/// </remarks>
|
||||||
|
internal async Task SendAsync(
|
||||||
|
TerminalClientOpcode opcode,
|
||||||
|
uint sessionId,
|
||||||
|
ReadOnlyMemory<byte> payload,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await socket
|
||||||
|
.SendAsync(
|
||||||
|
TerminalFrame.Create((byte)opcode, sessionId, payload.Span),
|
||||||
|
WebSocketMessageType.Binary,
|
||||||
|
endOfMessage: true,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
/// <summary>Reads frames until one carries that opcode, and answers with it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Skipping rather than expecting the next frame to be the interesting one, because a live session's
|
||||||
|
/// socket is not quiet: the shell's opening frame, its activation and whatever the remote said are all
|
||||||
|
/// on the way, and which of them have arrived by the time a test looks is a scheduling accident.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Bounded only by the caller's token, which in this suite is the test's own — so a frame that never
|
||||||
|
/// comes fails the test on its timeout rather than hanging the run.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal async Task<byte[]> WaitForAsync(
|
||||||
|
TerminalServerOpcode opcode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var buffer = new byte[64 * 1024];
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var result = await socket
|
||||||
|
.ReceiveAsync(buffer.AsMemory(), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (result.MessageType == WebSocketMessageType.Close)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"The host closed the socket before sending a {opcode} frame.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var read, out _, out var payload)
|
||||||
|
&& read == (byte)opcode)
|
||||||
|
{
|
||||||
|
return payload.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,6 +45,31 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal List<SshConnectionRequest> SftpRequests { get; } = [];
|
internal List<SshConnectionRequest> SftpRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The furthest phase a failing attempt reports before <see cref="Failure"/> is thrown, or null for one
|
||||||
|
/// that never reports any.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>It is what decides whether the shell goes and probes the endpoint.</b> A connection that stopped
|
||||||
|
/// while reaching is worth asking a socket about; one that got a host key has plainly reached the machine,
|
||||||
|
/// and one that never reported a phase never dialled at all. See <c>VaultViewModel.ExplainAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Defaults to <see cref="SshConnectionPhase.Reaching"/>, which is what this fake reported
|
||||||
|
/// unconditionally before the field existed, so every test written against it goes on meaning what it did.
|
||||||
|
/// Null models the real factory throwing out of <c>BuildConnectionInfo</c> — a private key that will not
|
||||||
|
/// parse — which happens before it reports anything.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal SshConnectionPhase? FailAfter { get; set; } = SshConnectionPhase.Reaching;
|
||||||
|
|
||||||
|
/// <summary>Every phase up to and including <paramref name="furthest"/>, in the order they happen.</summary>
|
||||||
|
private static IEnumerable<SshConnectionPhase> PhasesUpTo(SshConnectionPhase? furthest) =>
|
||||||
|
furthest is { } limit
|
||||||
|
? Enum.GetValues<SshConnectionPhase>().Where(phase => phase <= limit)
|
||||||
|
: [];
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ISshConnection> ConnectAsync(
|
public async Task<ISshConnection> ConnectAsync(
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
@@ -56,7 +81,12 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
|||||||
// Before the gate rather than after it, which is what makes this fake useful for the connecting
|
// Before the gate rather than after it, which is what makes this fake useful for the connecting
|
||||||
// card: a test that holds Gate open is a connection stuck partway through, and the step list has to
|
// card: a test that holds Gate open is a connection stuck partway through, and the step list has to
|
||||||
// show it stuck on a named step rather than on none.
|
// show it stuck on a named step rather than on none.
|
||||||
progress?.Report(SshConnectionPhase.Reaching);
|
//
|
||||||
|
// How far a failing attempt gets is FailAfter's to say; a succeeding one is always reaching by now.
|
||||||
|
foreach (var phase in Failure is null ? [SshConnectionPhase.Reaching] : PhasesUpTo(FailAfter))
|
||||||
|
{
|
||||||
|
progress?.Report(phase);
|
||||||
|
}
|
||||||
|
|
||||||
if (Gate is { } gate)
|
if (Gate is { } gate)
|
||||||
{
|
{
|
||||||
@@ -419,3 +449,42 @@ internal sealed class FakeSshShellSession : ISshShellSession
|
|||||||
closed.Dispose();
|
closed.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A reachability probe that answers from a script instead of from the network.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Every shell in this suite is given one, and that is not only about speed.</b> The real probe resolves
|
||||||
|
/// a name and opens a socket — see <see cref="SshReachabilityProbe"/> — so a fixture without this would put a
|
||||||
|
/// live DNS lookup for <c>db.internal</c> and a live TCP connect in the middle of a unit suite, on every test
|
||||||
|
/// where a connection fails. That is slow, it needs a network, and it is worse than either: <c>db.internal</c>
|
||||||
|
/// resolves on any machine whose resolver has a search domain that makes it resolve, so the sentence the
|
||||||
|
/// assertions read would depend on whose network the suite was run from.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Summary"/> defaults to empty, which is what keeps the tests that predate the probe asserting
|
||||||
|
/// the exact message they always did: an empty finding appends nothing, so those cases go on describing a
|
||||||
|
/// failure and nothing else. A test that is about the diagnosis sets it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class FakeReachabilityProbe : ISshReachabilityProbe
|
||||||
|
{
|
||||||
|
/// <summary>What every probe comes back with. Empty adds nothing to the failure it explains.</summary>
|
||||||
|
internal string Summary { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>What it was asked about, in order, so a test can assert it was not asked at all.</summary>
|
||||||
|
internal List<string> Probed { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask<SshReachabilityReport> ProbeAsync(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Probed.Add($"{host}:{port}");
|
||||||
|
|
||||||
|
return ValueTask.FromResult(
|
||||||
|
new SshReachabilityReport(SshReach.Open, host, port, host, TimeSpan.Zero, Summary));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Import;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Hetzner Cloud wizard, from a token to hosts on the server.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// What this establishes beyond <c>HetznerCloudClientTests</c> is the half that suite cannot reach: that
|
||||||
|
/// reading a project stores nothing, that importing goes through the ordinary create-and-push path every
|
||||||
|
/// other write uses, that a server the keychain already points at arrives unticked rather than silently
|
||||||
|
/// duplicated, and that the token does not outlive the request it was typed for.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Hetzner is a stubbed <c>HttpMessageHandler</c>. Everything else — the vault, the cache, the server — is
|
||||||
|
/// this fixture's real one, which is the point: the assertion that matters most is that the imported hosts
|
||||||
|
/// reached the server without anything pressing Sync.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed partial class ShellFlowTests
|
||||||
|
{
|
||||||
|
private const string HetznerToken =
|
||||||
|
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The whole wizard.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One server already in the keychain and one not, so the two halves of the default are both exercised:
|
||||||
|
/// the new one arrives ticked, the known one does not, and only the ticked one is stored.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportingFromHetzner_ShowsTheProjectFirstAndThenStoresWhatWasTicked()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
// db.internal:deploy is what AddHostAsync creates, so the first server is one the keychain has.
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
var wizard = Wizard(Project("""
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "already-here",
|
||||||
|
"status": "running",
|
||||||
|
"public_net": { "ipv4": { "ip": "db.internal" } }
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "web-01",
|
||||||
|
"status": "running",
|
||||||
|
"public_net": { "ipv4": { "ip": "203.0.113.10" } },
|
||||||
|
"server_type": { "name": "cx22" },
|
||||||
|
"datacenter": { "name": "fsn1-dc14", "location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" } },
|
||||||
|
"image": { "name": "ubuntu-24.04" }
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
wizard.Step.ShouldBe(HetznerImportStep.Token, "it opens on the box, having sent nothing");
|
||||||
|
|
||||||
|
wizard.Token = HetznerToken;
|
||||||
|
wizard.Username = "deploy";
|
||||||
|
|
||||||
|
await wizard.ScanCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
wizard.Step.ShouldBe(HetznerImportStep.Choose);
|
||||||
|
wizard.Rows.Count.ShouldBe(2);
|
||||||
|
vault.Hosts.Count.ShouldBe(1, "reading the project stores nothing");
|
||||||
|
|
||||||
|
var known = wizard.Rows.Single(row => string.Equals(row.Name, "already-here", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
known.AlreadyPresent.ShouldBeTrue("it points at a machine the keychain already has");
|
||||||
|
known.IsSelected.ShouldBeFalse("a duplicate takes a click rather than being the default");
|
||||||
|
|
||||||
|
var fresh = wizard.Rows.Single(row => string.Equals(row.Name, "web-01", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
fresh.IsSelected.ShouldBeTrue();
|
||||||
|
fresh.Address.ShouldBe("203.0.113.10");
|
||||||
|
|
||||||
|
await wizard.ImportCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
wizard.Step.ShouldBe(HetznerImportStep.Done);
|
||||||
|
|
||||||
|
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
imported.Address.ShouldBe("deploy@203.0.113.10:22");
|
||||||
|
imported.Host.SshKeyId.ShouldBeNull("Hetzner has no private key to hand over");
|
||||||
|
imported.Host.Notes.ShouldNotBeNull().ShouldContain("cx22");
|
||||||
|
|
||||||
|
vault.Hosts.Count.ShouldBe(2, "only the ticked one was stored");
|
||||||
|
|
||||||
|
// Through the ordinary path, which is the point of routing it through the vault: it reached the
|
||||||
|
// server without anything pressing Sync.
|
||||||
|
server.LiveRowCount.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The token does not outlive the request it was typed for.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the whole of what makes pasting a credential capable of listing somebody's estate into a text
|
||||||
|
/// box acceptable: it is used once and dropped. Asserted on the success path deliberately — a wizard
|
||||||
|
/// that cleared the box only when something went wrong would leave the token sitting there for the
|
||||||
|
/// entire time somebody spends reading a list of forty servers.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTokenIsForgottenAsSoonAsTheProjectHasBeenRead()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var wizard = Wizard(Project(WebServer));
|
||||||
|
|
||||||
|
wizard.Token = HetznerToken;
|
||||||
|
|
||||||
|
await wizard.ScanCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
wizard.Token.ShouldBeEmpty();
|
||||||
|
wizard.Rows.ShouldHaveSingleItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A refused token is the failure this meets most often, and the recovery is to type a different one —
|
||||||
|
/// so it stays on the step that has the box, with a message that says where tokens come from.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ARefusedToken_StaysOnTheTokenStepAndStoresNothing()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var wizard = Wizard(new StubHetznerResponse(
|
||||||
|
HttpStatusCode.Unauthorized,
|
||||||
|
"""{ "error": { "code": "unauthorized", "message": "unable to authenticate" } }"""));
|
||||||
|
|
||||||
|
wizard.Token = HetznerToken;
|
||||||
|
|
||||||
|
await wizard.ScanCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
wizard.Step.ShouldBe(HetznerImportStep.Token);
|
||||||
|
wizard.Status.ShouldContain("API tokens");
|
||||||
|
wizard.Rows.ShouldBeEmpty();
|
||||||
|
wizard.Token.ShouldBeEmpty("a refused token is retyped rather than corrected in place");
|
||||||
|
shell.Vault!.Hosts.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// An empty project is a correct answer to a correct token, so it is not an error — and it is not a step
|
||||||
|
/// forward either, because a list screen with nothing on it says less than the sentence does.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEmptyProject_SaysSoWithoutMovingOn()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var wizard = Wizard(Project());
|
||||||
|
|
||||||
|
wizard.Token = HetznerToken;
|
||||||
|
|
||||||
|
await wizard.ScanCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
wizard.Step.ShouldBe(HetznerImportStep.Token);
|
||||||
|
wizard.Status.ShouldContain("no servers");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Choosing the private network does not quietly put a public address in the keychain.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The one asymmetry in how addresses resolve, and the reason for it: falling back between v4 and v6
|
||||||
|
/// reaches the same machine over the same internet, where falling back from a private address to a
|
||||||
|
/// public one would put an address on the open internet into a bookmark that was asked to stay off it.
|
||||||
|
/// So the row loses its tick and cannot get it back while that choice stands.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ChoosingThePrivateNetwork_LeavesAServerWithoutOneUnimportable()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var wizard = Wizard(Project(WebServer, """
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "db-01",
|
||||||
|
"status": "running",
|
||||||
|
"public_net": { "ipv4": { "ip": "203.0.113.20" } },
|
||||||
|
"private_net": [ { "ip": "10.0.0.4" } ]
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
wizard.Token = HetznerToken;
|
||||||
|
|
||||||
|
await wizard.ScanCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
wizard.SelectedCount.ShouldBe(2);
|
||||||
|
|
||||||
|
wizard.UseAddressCommand.Execute(HetznerAddressKind.Private);
|
||||||
|
|
||||||
|
var public_ = wizard.Rows.Single(row => string.Equals(row.Name, "web-01", StringComparison.Ordinal));
|
||||||
|
var inside = wizard.Rows.Single(row => string.Equals(row.Name, "db-01", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
public_.CanImport.ShouldBeFalse("it is on no private network");
|
||||||
|
public_.IsSelected.ShouldBeFalse();
|
||||||
|
public_.Address.ShouldBe("—");
|
||||||
|
|
||||||
|
inside.CanImport.ShouldBeTrue();
|
||||||
|
inside.Address.ShouldBe("10.0.0.4");
|
||||||
|
|
||||||
|
wizard.SelectedCount.ShouldBe(1);
|
||||||
|
|
||||||
|
// Neither direction of "tick everything" can reach it, which is what "not importable" has to mean.
|
||||||
|
wizard.ToggleAllCommand.Execute(null);
|
||||||
|
|
||||||
|
public_.IsSelected.ShouldBeFalse();
|
||||||
|
inside.IsSelected.ShouldBeFalse("that press was the untick, since everything reachable was ticked");
|
||||||
|
|
||||||
|
wizard.ToggleAllCommand.Execute(null);
|
||||||
|
|
||||||
|
public_.IsSelected.ShouldBeFalse();
|
||||||
|
inside.IsSelected.ShouldBeTrue();
|
||||||
|
|
||||||
|
await wizard.ImportCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Vault!.Hosts.ShouldHaveSingleItem().Host.Hostname.ShouldBe("10.0.0.4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Changing the address choice must not throw away ticks somebody set — but changing the login user has
|
||||||
|
/// to re-ask "is this already here", because the user is half of what that is decided on.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangingTheLoginUser_ReasksWhetherTheKeychainAlreadyHasIt()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
var wizard = Wizard(Project("""
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "db",
|
||||||
|
"status": "running",
|
||||||
|
"public_net": { "ipv4": { "ip": "db.internal" } }
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
wizard.Token = HetznerToken;
|
||||||
|
wizard.Username = "deploy";
|
||||||
|
|
||||||
|
await wizard.ScanCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
wizard.Rows.ShouldHaveSingleItem().AlreadyPresent.ShouldBeTrue();
|
||||||
|
|
||||||
|
wizard.Username = "ubuntu";
|
||||||
|
|
||||||
|
wizard.Rows[0].AlreadyPresent.ShouldBeFalse("a different account on that machine is a different host");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A server with no public IPv4 still imports, and the row says what was assumed to reach it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// IPv6-only is a whole Hetzner price tier rather than an edge case. The address is the first in the
|
||||||
|
/// routed /64 — the API reports a network, which nothing can dial — and that is a convention of
|
||||||
|
/// Hetzner's own images rather than a fact, so it is on the row and in the stored note.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnIPv6OnlyServer_ImportsWithWhatWasAssumedWrittenDown()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var wizard = Wizard(Project("""
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "v6-only",
|
||||||
|
"status": "running",
|
||||||
|
"public_net": { "ipv4": null, "ipv6": { "ip": "2a01:4f8:1c17:d00::/64" } }
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
wizard.Token = HetznerToken;
|
||||||
|
|
||||||
|
await wizard.ScanCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var row = wizard.Rows.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
row.Address.ShouldBe("2a01:4f8:1c17:d00::1");
|
||||||
|
row.HasWarning.ShouldBeTrue();
|
||||||
|
row.Meaning.ShouldContain("No public IPv4");
|
||||||
|
|
||||||
|
await wizard.ImportCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var host = shell.Vault!.Hosts.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
host.Host.Hostname.ShouldBe("2a01:4f8:1c17:d00::1");
|
||||||
|
host.Host.Notes.ShouldNotBeNull().ShouldContain("No public IPv4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// After an import the list is asked again rather than cleared, so a row that was just stored says
|
||||||
|
/// "already here" — which is what makes coming back and importing the rest of the project harmless and
|
||||||
|
/// visible rather than harmless and confusing.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AfterImporting_TheListSaysWhichOnesAreNowHere()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var wizard = Wizard(Project(WebServer));
|
||||||
|
|
||||||
|
wizard.Token = HetznerToken;
|
||||||
|
|
||||||
|
await wizard.ScanCommand.ExecuteAsync(null);
|
||||||
|
await wizard.ImportCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
wizard.ReviewCommand.Execute(null);
|
||||||
|
|
||||||
|
wizard.Step.ShouldBe(HetznerImportStep.Choose);
|
||||||
|
|
||||||
|
var row = wizard.Rows.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
row.AlreadyPresent.ShouldBeTrue();
|
||||||
|
row.IsSelected.ShouldBeFalse("importing it twice takes a click");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Where it lives in the shell ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The wizard is drawn over the Preferences page inside settings mode, exactly as the ssh_config
|
||||||
|
/// importer is: the nav stays lit on Preferences and the titlebar offers "Back to preferences". What
|
||||||
|
/// would silently come apart is the content column — two importers and one page beneath them.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheHetznerWizardOpensOverPreferencesAndBacksOutToIt()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
|
||||||
|
|
||||||
|
shell.IsHetznerImportOpen.ShouldBeTrue();
|
||||||
|
shell.IsImporterOpen.ShouldBeTrue("the titlebar's back button asks this one");
|
||||||
|
shell.IsSettingsPreferencesPage.ShouldBeTrue("the nav stays lit on Preferences");
|
||||||
|
shell.IsSettingsPreferencesContentShowing.ShouldBeFalse("the wizard is what the column draws");
|
||||||
|
|
||||||
|
shell.CloseImportCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.IsHetznerImportOpen.ShouldBeFalse();
|
||||||
|
shell.IsSettingsMode.ShouldBeTrue("back to preferences, not out of settings");
|
||||||
|
shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Opening one closes the other. They share a content column, and two importers drawn over each other
|
||||||
|
/// would leave whichever was on top looking like the one the back button belongs to.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task OpeningOneImporterClosesTheOther()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
||||||
|
shell.IsImportOpen.ShouldBeTrue();
|
||||||
|
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
|
||||||
|
|
||||||
|
shell.IsImportOpen.ShouldBeFalse();
|
||||||
|
shell.IsHetznerImportOpen.ShouldBeTrue();
|
||||||
|
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
||||||
|
|
||||||
|
shell.IsHetznerImportOpen.ShouldBeFalse();
|
||||||
|
shell.IsImportOpen.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reopening the wizard puts it back on step one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A decision rather than an omission. The first step holds a credential this deliberately does not
|
||||||
|
/// keep, so a "resumed" second step would be a list nothing on screen has the means to refresh — and it
|
||||||
|
/// would go on looking current for as long as somebody left the application open.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReopeningTheWizard_StartsAtTheTokenAgain()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var wizard = shell.HetznerImportScreen.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Driven through the shell's own view model rather than the one this file builds, because what is
|
||||||
|
// being checked is what OpenHetznerImport does to the screen the shell is holding.
|
||||||
|
wizard.Token = HetznerToken;
|
||||||
|
wizard.Step = HetznerImportStep.Choose;
|
||||||
|
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
|
||||||
|
|
||||||
|
wizard.Step.ShouldBe(HetznerImportStep.Token);
|
||||||
|
wizard.Rows.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
/// <summary>A server carrying enough to be imported, used wherever the shape is not what is being tested.</summary>
|
||||||
|
/// <summary>
|
||||||
|
/// The two answers this import used to decide on somebody's behalf: which vault, and which key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>A Hetzner project is the case where "wherever I happen to be standing" is the wrong vault.</b>
|
||||||
|
/// The <c>ssh_config</c> importer reads this machine's own account and files into the active vault
|
||||||
|
/// fairly; a project belongs to a team more often than to a person, and forty servers landing in
|
||||||
|
/// somebody's personal vault are either forty items nobody else can see or forty moved one at a time.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The key is the half Hetzner cannot supply and the keychain usually already holds.</b> The public
|
||||||
|
/// half went to Hetzner when the servers were made; the private half never did. So the import points at
|
||||||
|
/// a key rather than bringing one — the host stores its id, exactly as one bound by hand does, and the
|
||||||
|
/// material is neither read nor copied. Without it this screen's honest answer was forty hosts asking
|
||||||
|
/// for a password.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The key here is deliberately left in the personal vault while the hosts go to the shared one, which
|
||||||
|
/// is allowed and is the one thing about it worth saying out loud: a binding resolves across every vault
|
||||||
|
/// <em>this</em> account can read, so these hosts connect here and not for a colleague who holds only
|
||||||
|
/// the destination's key.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportingIntoAChosenVault_BindsEveryServerToTheChosenKey()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
|
await vaults.LoadAsync(Token);
|
||||||
|
|
||||||
|
vaults.NewVaultCommand.Execute(null);
|
||||||
|
vaults.NewVaultName = "Platform secrets";
|
||||||
|
|
||||||
|
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
|
||||||
|
var keyId = vault.Keys.ShouldHaveSingleItem().EntityId;
|
||||||
|
|
||||||
|
var wizard = Wizard(Project(WebServer));
|
||||||
|
|
||||||
|
wizard.Token = HetznerToken;
|
||||||
|
|
||||||
|
await wizard.ScanCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
wizard.HasVaultChoice.ShouldBeTrue("there are two vaults to choose between");
|
||||||
|
wizard.HasKeyChoice.ShouldBeTrue("the keychain has a key to point at");
|
||||||
|
wizard.SelectedKey.ShouldNotBeNull().KeyId.ShouldBeNull("binding a key is chosen, never defaulted");
|
||||||
|
|
||||||
|
wizard.SelectedVault = wizard.VaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||||
|
wizard.SelectedKey = wizard.KeyChoices.Single(choice => choice.KeyId == keyId);
|
||||||
|
|
||||||
|
wizard.AuthenticationNote.ShouldContain("deploy");
|
||||||
|
wizard.AuthenticationNote.ShouldContain(
|
||||||
|
"another vault",
|
||||||
|
Case.Sensitive,
|
||||||
|
"the key stayed behind, and the colleagues these hosts just joined cannot resolve it");
|
||||||
|
|
||||||
|
await wizard.ImportCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
imported.VaultId.ShouldBe(sharedVaultId, wizard.Status);
|
||||||
|
imported.Host.SshKeyId.ShouldBe(keyId, "pointed at the key rather than carrying one");
|
||||||
|
wizard.Status.ShouldContain("Platform secrets");
|
||||||
|
|
||||||
|
// Through the ordinary path, as every other write here is: it reached the server without anything
|
||||||
|
// pressing Sync.
|
||||||
|
server.LiveRowCount.ShouldBe(2, "the key and the host");
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string WebServer = """
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "web-01",
|
||||||
|
"status": "running",
|
||||||
|
"public_net": { "ipv4": { "ip": "203.0.113.10" } }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <summary>The wizard, over a Hetzner that answers with exactly this.</summary>
|
||||||
|
private HetznerImportViewModel Wizard(StubHetznerResponse response) =>
|
||||||
|
new(shell.Vault!, new HetznerCloudClient(new HttpClient(new StubHetzner(response))));
|
||||||
|
|
||||||
|
/// <summary>One page holding these servers and saying there are no more.</summary>
|
||||||
|
private static StubHetznerResponse Project(params string[] servers) =>
|
||||||
|
new(
|
||||||
|
HttpStatusCode.OK,
|
||||||
|
$$"""
|
||||||
|
{
|
||||||
|
"servers": [ {{string.Join(",", servers)}} ],
|
||||||
|
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": null } }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
private sealed record StubHetznerResponse(HttpStatusCode Status, string Body);
|
||||||
|
|
||||||
|
/// <summary>Answers every request the same way. What varies between these tests is the body, not the sequence.</summary>
|
||||||
|
private sealed class StubHetzner(StubHetznerResponse response) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(new HttpResponseMessage(response.Status)
|
||||||
|
{
|
||||||
|
Content = new StringContent(response.Body, Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,27 @@ namespace DodoSSH.Client.App.Tests;
|
|||||||
/// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test
|
/// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test
|
||||||
/// rather than by remembering to click through them.
|
/// rather than by remembering to click through them.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class ShellFlowTests : IAsyncLifetime
|
/// <remarks>
|
||||||
|
/// Split across two files rather than one: the Hetzner wizard's own path is in
|
||||||
|
/// <c>ShellFlowTests.Hetzner.cs</c>, which needs this fixture's unlocked vault and nothing else of it. The
|
||||||
|
/// same reason <c>FakeVaultServer</c> keeps its team endpoints in a file of their own.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private const string Passphrase = "a sufficiently long passphrase";
|
private const string Passphrase = "a sufficiently long passphrase";
|
||||||
|
|
||||||
/// <summary>Everything this shell has copied, newest last.</summary>
|
/// <summary>Everything this shell has copied, newest last.</summary>
|
||||||
private readonly List<string> clipboard = [];
|
private readonly List<string> clipboard = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the machine's clipboard holds, as far as this shell can read it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Null rather than empty by default, which is what a clipboard holding something that is not text
|
||||||
|
/// answers. Set by the one test that needs the shell to read one — the terminal's right-click paste.
|
||||||
|
/// </remarks>
|
||||||
|
private string? machineClipboard;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Far below the shipped profile, for the same reason as everywhere else: these tests are about the
|
/// Far below the shipped profile, for the same reason as everywhere else: these tests are about the
|
||||||
/// state machine, not about how expensive the passphrase is to attack.
|
/// state machine, not about how expensive the passphrase is to attack.
|
||||||
@@ -49,6 +63,15 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private readonly FakeSshConnectionFactory ssh = new();
|
private readonly FakeSshConnectionFactory ssh = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The scripted answer behind every "why did that not connect" in this file.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Silent by default — see <see cref="FakeReachabilityProbe.Summary"/> — so the cases written before the
|
||||||
|
/// diagnosis existed go on asserting the exact failure message they always did.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly FakeReachabilityProbe reachability = new();
|
||||||
|
|
||||||
private int signInAttempts;
|
private int signInAttempts;
|
||||||
|
|
||||||
/// <summary>How many times a shell has tried to resume a remembered sign-in, and with what.</summary>
|
/// <summary>How many times a shell has tried to resume a remembered sign-in, and with what.</summary>
|
||||||
@@ -164,6 +187,10 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// And the other direction, which only the terminal's right-click paste uses. A closure over the
|
||||||
|
// field rather than a value captured now, because a test sets it after the shell is built.
|
||||||
|
readClipboard: () => Task.FromResult(machineClipboard),
|
||||||
|
|
||||||
// Inline, because this suite has no window and therefore no dispatcher to drain — the same
|
// Inline, because this suite has no window and therefore no dispatcher to drain — the same
|
||||||
// answer TransferQueueingTests reached, and for the reason its own remark gives: reaching
|
// answer TransferQueueingTests reached, and for the reason its own remark gives: reaching
|
||||||
// Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched
|
// Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched
|
||||||
@@ -194,7 +221,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
save = onChanged;
|
save = onChanged;
|
||||||
return new StopWatching(() => save = null);
|
return new StopWatching(() => save = null);
|
||||||
});
|
},
|
||||||
|
|
||||||
|
// Scripted rather than real, so that a failed connection in any test in this file does not go
|
||||||
|
// and do a DNS lookup and open a socket. See FakeReachabilityProbe for why that matters beyond
|
||||||
|
// the wall clock.
|
||||||
|
reachability: reachability);
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -294,6 +326,90 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- The terminal's right-click, which is a copy or a paste ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The gesture is heard by the page and nowhere else — the selection is drawn into a canvas, and a
|
||||||
|
/// focused terminal leaves the host's window no input events at all — so the shell's part is to be the
|
||||||
|
/// clipboard. This is that wiring end to end: a real frame on the real socket, through the workspace, to
|
||||||
|
/// the delegate a head supplies.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Sent with no session open at all, deliberately. A copy is not about a terminal, and the frame is
|
||||||
|
/// dispatched before the session lookup precisely so that copying the last thing a dead tab said still
|
||||||
|
/// works — which is when somebody is most likely to want it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ARightClickCopy_ReachesTheMachinesClipboard()
|
||||||
|
{
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
await renderer.SendAsync(
|
||||||
|
TerminalClientOpcode.ClipboardCopy,
|
||||||
|
sessionId: 0,
|
||||||
|
System.Text.Encoding.UTF8.GetBytes("error: nothing was written"),
|
||||||
|
Token);
|
||||||
|
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => clipboard.Contains("error: nothing was written", StringComparer.Ordinal),
|
||||||
|
"the copy frame should have reached the clipboard delegate");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other direction, and the half the page cannot do for itself: reading a clipboard from a WebView
|
||||||
|
/// needs the <c>clipboard-read</c> permission, which an embedded WebView answers with a prompt. So the
|
||||||
|
/// page asks, the shell reads, and the answer goes back down the same socket addressed to the pane that
|
||||||
|
/// asked — which is what this follows all the way round.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ARightClickPaste_SendsTheMachinesClipboardBackToThePaneThatAsked()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var sessionId = shell.SelectedTab.ShouldNotBeNull().SessionId;
|
||||||
|
|
||||||
|
machineClipboard = "sudo systemctl restart sshd";
|
||||||
|
|
||||||
|
await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token);
|
||||||
|
|
||||||
|
var payload = await renderer.WaitForAsync(TerminalServerOpcode.Clipboard, Token);
|
||||||
|
|
||||||
|
System.Text.Encoding.UTF8.GetString(payload).ShouldBe("sudo systemctl restart sshd");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// An empty clipboard is answered with nothing rather than with an empty paste, which the page would
|
||||||
|
/// bracket and hand the remote as a keystroke nobody made. Proven by the frame that <em>does</em> come
|
||||||
|
/// after it: the receive loop is sequential, so a clipboard frame for the second request arriving means
|
||||||
|
/// the first was seen and passed over.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ARightClickPaste_SendsNothingWhenTheClipboardHasNoText()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var sessionId = shell.SelectedTab.ShouldNotBeNull().SessionId;
|
||||||
|
|
||||||
|
machineClipboard = null;
|
||||||
|
await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token);
|
||||||
|
|
||||||
|
machineClipboard = "uptime";
|
||||||
|
await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token);
|
||||||
|
|
||||||
|
var payload = await renderer.WaitForAsync(TerminalServerOpcode.Clipboard, Token);
|
||||||
|
|
||||||
|
System.Text.Encoding.UTF8.GetString(payload)
|
||||||
|
.ShouldBe("uptime", "the empty clipboard sent no frame of its own");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AFreshMachine_AsksForAServer()
|
public async Task AFreshMachine_AsksForAServer()
|
||||||
{
|
{
|
||||||
@@ -1983,6 +2099,82 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
"the session that was replaced should have been released with its pane");
|
"the session that was replaced should have been released with its pane");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ THE ONE THIS EXISTS FOR: a reconnect that dialled a different machine of the same name. Nothing in
|
||||||
|
/// this application stops two hosts being called the same thing — a team's vault beside a personal one
|
||||||
|
/// makes it ordinary, and "prod-db" is what a database server gets called in both — and a tab used to
|
||||||
|
/// find its host by that name. A dropped session on one machine then came back as a live shell on the
|
||||||
|
/// other, with the tab, the address and the status bar all still naming the first.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asserted on the address actually dialled rather than on anything the tab says about itself. The two
|
||||||
|
/// rows agree on every string a tab carries, which is exactly why this went unnoticed: a test written
|
||||||
|
/// against the label would have passed on the day the wrong machine was being opened.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReconnectingATabWhoseHostHasANamesake_DialsTheMachineTheTabWasOpenedOn()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
|
||||||
|
// A second host under the name the fixture's own already has, on a different machine.
|
||||||
|
await AddHostAsync(vault, "prod-db", "db-replica.internal");
|
||||||
|
|
||||||
|
// Both bound to the key, so neither is diverted to the password sheet: what this asserts is which of
|
||||||
|
// the two gets dialled, not whether either can be. Re-found between the two, because saving a host
|
||||||
|
// rebuilds the list.
|
||||||
|
await BindKeyAsync(vault, Machine(vault, "db.internal"), vault.Keys[0].EntityId);
|
||||||
|
await BindKeyAsync(vault, Machine(vault, "db-replica.internal"), vault.Keys[0].EntityId);
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectToRowCommand.ExecuteAsync(Machine(vault, "db-replica.internal"));
|
||||||
|
|
||||||
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||||
|
ssh.Requests.ShouldHaveSingleItem().Host.ShouldBe("db-replica.internal");
|
||||||
|
|
||||||
|
ssh.Shells[0].Drop("An established connection was aborted by the server.");
|
||||||
|
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||||
|
|
||||||
|
shell.CanReconnectSelectedTab.ShouldBeTrue("the machine this tab was opened on is still there");
|
||||||
|
|
||||||
|
await shell.ReconnectTabCommand.ExecuteAsync(tab);
|
||||||
|
|
||||||
|
tab.IsLive.ShouldBeTrue(tab.Status);
|
||||||
|
|
||||||
|
ssh.Requests.Count.ShouldBe(2);
|
||||||
|
ssh.Requests[1].Host.ShouldBe(
|
||||||
|
"db-replica.internal",
|
||||||
|
"a reconnect dials the machine the tab was opened on, not the one that shares its name");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The same mix-up as the test above, where it is read rather than dialled. A green dot is the host list
|
||||||
|
/// answering "am I on this machine", and the name two rows share is not an answer to it — a dot on the
|
||||||
|
/// machine nobody has a shell on claims a session that does not exist, and takes QUICK ACCESS's pins with
|
||||||
|
/// it, since the sidebar offers the pinned folders of whichever row that same match found.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectingToOneOfTwoHostsOfTheSameName_LightsThatOneAlone()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db", "db-replica.internal");
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
vault.SelectedHost = Machine(vault, "db-replica.internal");
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Tabs.ShouldHaveSingleItem().IsLive.ShouldBeTrue();
|
||||||
|
|
||||||
|
Machine(vault, "db-replica.internal").IsConnected.ShouldBeTrue();
|
||||||
|
Machine(vault, "db.internal").IsConnected.ShouldBeFalse(
|
||||||
|
"nobody has a shell on this machine; only its name was connected to");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A connection that was refused is worth retrying for the same reason a dropped one is, and the
|
/// A connection that was refused is worth retrying for the same reason a dropped one is, and the
|
||||||
/// commonest refusal — a machine that was not up yet — is the one most likely to succeed a minute later.
|
/// commonest refusal — a machine that was not up yet — is the one most likely to succeed a minute later.
|
||||||
@@ -2414,6 +2606,84 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
tab.Steps[1].IsRunning.ShouldBeTrue("nothing moved it on after the shell let go of it");
|
tab.Steps[1].IsRunning.ShouldBeTrue("nothing moved it on after the shell let go of it");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The reason this exists: "Connection has timed out." is what SSH.NET says for two different
|
||||||
|
/// faults.</b> A machine that never answered and a machine that answered TCP and then never spoke SSH are
|
||||||
|
/// the same five words and nothing else, and they are not the same problem. So a failure that stopped
|
||||||
|
/// while reaching now goes and looks, and what it finds is added to what was said.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The finding is appended rather than substituted, which is the half worth asserting: the exception is
|
||||||
|
/// the fact and the probe is a second measurement, and a test that only checked for the new sentence
|
||||||
|
/// would pass against an implementation that had thrown the original away.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AConnectionThatCouldNotReachItsHost_SaysWhatIsActuallyWrongWithIt()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
ssh.Failure = new InvalidOperationException("Connection has timed out.");
|
||||||
|
reachability.Summary = "Port 22 on db.internal accepts connections.";
|
||||||
|
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
tab.Status.ShouldBe("Connection has timed out. Port 22 on db.internal accepts connections.");
|
||||||
|
reachability.Probed.ShouldHaveSingleItem().ShouldBe("db.internal:22");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Past <see cref="SshConnectionPhase.Reaching"/> the machine has offered a host key, so it is plainly
|
||||||
|
/// reachable and there is nothing a socket could add. A refused password that came back with "the network
|
||||||
|
/// is fine and something is listening" bolted on would be true, useless, and read as though the client
|
||||||
|
/// had not understood the question.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AConnectionRefusedAfterTheHostKey_IsNotProbed()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
ssh.FailAfter = SshConnectionPhase.Authenticating;
|
||||||
|
ssh.Failure = new InvalidOperationException("Permission denied (publickey,password).");
|
||||||
|
reachability.Summary = "this must not be appended";
|
||||||
|
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Tabs.ShouldHaveSingleItem().Status.ShouldBe("Permission denied (publickey,password).");
|
||||||
|
reachability.Probed.ShouldBeEmpty("the host key proved the machine was reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>The other end of the window, and the one that would state something false.</b> A failure with no
|
||||||
|
/// phase reported at all never dialled — the real factory throws out of <c>BuildConnectionInfo</c> on a
|
||||||
|
/// private key it cannot parse, before the socket is opened. Probing there finds a perfectly healthy port
|
||||||
|
/// and appends "the SSH handshake is what did not finish" to a message about an unreadable key, which is
|
||||||
|
/// a confident wrong answer replacing an accurate one.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AFailureBeforeAnythingWasDialled_IsNotProbed()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
ssh.FailAfter = null;
|
||||||
|
ssh.Failure = new InvalidOperationException("The private key could not be read.");
|
||||||
|
reachability.Summary = "this must not be appended";
|
||||||
|
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Tabs.ShouldHaveSingleItem().Status.ShouldBe("The private key could not be read.");
|
||||||
|
reachability.Probed.ShouldBeEmpty("nothing had been dialled, so there was nothing to ask about");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
|
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
|
||||||
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
|
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
|
||||||
@@ -6383,6 +6653,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
Key: null,
|
Key: null,
|
||||||
KeyPath: null),
|
KeyPath: null),
|
||||||
],
|
],
|
||||||
|
vaultId: null,
|
||||||
Token);
|
Token);
|
||||||
|
|
||||||
vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id");
|
vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id");
|
||||||
@@ -6920,6 +7191,60 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
|
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The desktop's Ctrl-click, and the card the board already had lit.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The board always has a selection, so "one card lit, nothing ticked" is the state this screen
|
||||||
|
/// opens in</b> — a fresh unlock lights the first host so that CONNECT has something under it, see
|
||||||
|
/// <c>VaultViewModel.SelectionAfterReload</c>. A Ctrl-click there used to start a set of one, leaving
|
||||||
|
/// the machine the user was looking at out of the count and out of everything Delete, Move to another
|
||||||
|
/// vault and Change group then ran over. It is the omission somebody could act on without noticing:
|
||||||
|
/// two cards are marked on screen and the bar says one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The fold happens only into an empty set and never onto the card being clicked. The first is what
|
||||||
|
/// makes it a starting point rather than a card that keeps re-adding itself; the second is what keeps
|
||||||
|
/// every Ctrl-click visibly doing something.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ExtendingTheChoice_StartsTheSetWithTheCardTheBoardHasLit()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
await AddHostAsync(vault, "staging");
|
||||||
|
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.IsChoosingHosts.ShouldBeFalse();
|
||||||
|
|
||||||
|
vault.ExtendHostChoiceCommand.Execute(Host(vault, "staging"));
|
||||||
|
|
||||||
|
vault.ChosenHostCount.ShouldBe(2, "the lit card is in the set the Ctrl-click began");
|
||||||
|
Host(vault, "prod-db").IsChosen.ShouldBeTrue("which is the half that used to be left out");
|
||||||
|
Host(vault, "staging").IsChosen.ShouldBeTrue();
|
||||||
|
vault.SelectedHost.ShouldBeSameAs(Host(vault, "prod-db"), "ticking a card is not selecting it");
|
||||||
|
|
||||||
|
vault.ExtendHostChoiceCommand.Execute(Host(vault, "prod-db"));
|
||||||
|
|
||||||
|
vault.ChosenHostCount.ShouldBe(1, "a set that already exists is not folded into again");
|
||||||
|
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
|
||||||
|
|
||||||
|
vault.ExtendHostChoiceCommand.Execute(Host(vault, "staging"));
|
||||||
|
|
||||||
|
vault.IsChoosingHosts.ShouldBeFalse("emptying the set leaves the mode, as a tap does");
|
||||||
|
|
||||||
|
// The lit card itself, from empty: it is ticked rather than folded in and toggled straight back out,
|
||||||
|
// which would be a gesture with no effect and no way to tell it had been heard.
|
||||||
|
vault.ExtendHostChoiceCommand.Execute(Host(vault, "prod-db"));
|
||||||
|
|
||||||
|
vault.ChosenHostCount.ShouldBe(1);
|
||||||
|
Host(vault, "prod-db").IsChosen.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The set is held as entity ids rather than as rows, and this is why: every row object in the list is
|
/// The set is held as entity ids rather than as rows, and this is why: every row object in the list is
|
||||||
/// replaced on every synchronisation pass, so a set of rows would empty itself once a minute under
|
/// replaced on every synchronisation pass, so a set of rows would empty itself once a minute under
|
||||||
@@ -7135,6 +7460,97 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The board's outer level, which is the vault, and its inner one, which is the group.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>A group belongs to exactly one vault, and the board used to flatten that away.</b> Every group
|
||||||
|
/// of every readable vault was drawn in one run with a small grey vault name beside each heading, so two
|
||||||
|
/// vaults each holding a "Production" were two headings with the same word on them, told apart only by
|
||||||
|
/// reading the second line. Vaults are the headings now and groups sort the hosts inside one — see
|
||||||
|
/// <c>HostSectionViewModel.VaultHeading</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The fold state is the half worth a test rather than a comment. There is one "No group" heading per
|
||||||
|
/// vault now, and every one of them names no group at all — keyed on <c>Guid.Empty</c> as they were,
|
||||||
|
/// folding one vault's unfiled hosts away folded every other vault's with it. See
|
||||||
|
/// <c>SidebarGroupHeader.CollapseKey</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheHostBoard_PutsEachVaultsGroupsUnderItsOwnHeading()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
|
await vaults.LoadAsync(Token);
|
||||||
|
|
||||||
|
vaults.NewVaultCommand.Execute(null);
|
||||||
|
vaults.NewVaultName = "Platform secrets";
|
||||||
|
|
||||||
|
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
// The same group name in both vaults, which is the case the old board could not tell apart.
|
||||||
|
vault.NewGroupCommand.Execute(null);
|
||||||
|
vault.GroupEditorLabel = "Production";
|
||||||
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.NewGroupCommand.Execute(null);
|
||||||
|
vault.GroupEditorSelectedVault =
|
||||||
|
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||||
|
vault.GroupEditorLabel = "Production";
|
||||||
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var sharedGroupId = vault.Groups.Single(group => group.VaultId == sharedVaultId).EntityId;
|
||||||
|
|
||||||
|
// One unfiled host in each vault, and one filed under the shared vault's own group.
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
await AddHostInAsync(vault, sharedVaultId, "stage-web", groupId: null);
|
||||||
|
await AddHostInAsync(vault, sharedVaultId, "prod-web", sharedGroupId);
|
||||||
|
|
||||||
|
var sections = vault.HostSections.ToList();
|
||||||
|
var headings = sections.Where(section => section.StartsAVault).ToList();
|
||||||
|
|
||||||
|
headings.Count.ShouldBe(2, "one heading per vault, on the first section of each");
|
||||||
|
headings[1].VaultHeading.ShouldBe("PLATFORM SECRETS");
|
||||||
|
|
||||||
|
var opensTheShared = sections.IndexOf(headings[1]);
|
||||||
|
|
||||||
|
opensTheShared.ShouldBeGreaterThan(0, "the vault this session is standing in comes first");
|
||||||
|
|
||||||
|
sections.Take(opensTheShared).SelectMany(section => section.Hosts)
|
||||||
|
.ShouldAllBe(row => row.VaultId != sharedVaultId);
|
||||||
|
sections.Skip(opensTheShared).SelectMany(section => section.Hosts)
|
||||||
|
.ShouldAllBe(row => row.VaultId == sharedVaultId);
|
||||||
|
|
||||||
|
sections.Count(section => string.Equals(section.Header?.Label, "Production", StringComparison.Ordinal))
|
||||||
|
.ShouldBe(2, "each vault draws its own, rather than one heading for both");
|
||||||
|
|
||||||
|
sections.Where(section => section.Header is not null)
|
||||||
|
.ShouldAllBe(section => section.Header!.VaultBadge.Length == 0,
|
||||||
|
"the vault is named on the row above rather than beside every heading under it");
|
||||||
|
|
||||||
|
// ── The fold, which is per vault because the heading is ──
|
||||||
|
var unfiled = sections.Where(section => section.Header is { GroupId: null }).ToList();
|
||||||
|
|
||||||
|
unfiled.Count.ShouldBe(2, "each vault has unfiled hosts of its own");
|
||||||
|
|
||||||
|
vault.ToggleGroupCommand.Execute(unfiled[0].Header);
|
||||||
|
|
||||||
|
var folded = vault.HostSections.Where(section => section.Header is { GroupId: null }).ToList();
|
||||||
|
|
||||||
|
folded[0].Header!.IsExpanded.ShouldBeFalse();
|
||||||
|
folded[0].Hosts.ShouldBeEmpty("a folded section draws its heading and no cards");
|
||||||
|
folded[1].Header!.IsExpanded.ShouldBeTrue("one vault's unfiled hosts fold on their own");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The other refusal at the same door, and the one that needs two keychains to raise: a group is an
|
/// The other refusal at the same door, and the one that needs two keychains to raise: a group is an
|
||||||
/// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault
|
/// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault
|
||||||
@@ -10010,11 +10426,41 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task AddHostAsync(VaultViewModel vault, string label)
|
/// <summary>Adds a host to a named vault, optionally filed under one of that vault's groups.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The editor's own vault picker rather than the keychain screen's standing preference, which is the
|
||||||
|
/// order the two are meant to be used in: where a host goes is a field of the host in front of you. The
|
||||||
|
/// vault is set first because the group choices are that vault's — see
|
||||||
|
/// <c>VaultViewModel.EditorGroupChoices</c>.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task AddHostInAsync(
|
||||||
|
VaultViewModel vault,
|
||||||
|
Guid vaultId,
|
||||||
|
string label,
|
||||||
|
Guid? groupId)
|
||||||
|
{
|
||||||
|
vault.NewHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorSelectedVault =
|
||||||
|
vault.EditorVaultChoices.Single(choice => choice.VaultId == vaultId);
|
||||||
|
vault.EditorSelectedGroup =
|
||||||
|
vault.EditorGroupChoices.Single(choice => choice.EntityId == groupId);
|
||||||
|
|
||||||
|
vault.EditorLabel = label;
|
||||||
|
vault.EditorHostname = "db.internal";
|
||||||
|
vault.EditorUsername = "deploy";
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddHostAsync(
|
||||||
|
VaultViewModel vault,
|
||||||
|
string label,
|
||||||
|
string hostname = "db.internal")
|
||||||
{
|
{
|
||||||
vault.NewHostCommand.Execute(null);
|
vault.NewHostCommand.Execute(null);
|
||||||
vault.EditorLabel = label;
|
vault.EditorLabel = label;
|
||||||
vault.EditorHostname = "db.internal";
|
vault.EditorHostname = hostname;
|
||||||
vault.EditorUsername = "deploy";
|
vault.EditorUsername = "deploy";
|
||||||
|
|
||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
@@ -10038,6 +10484,14 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
||||||
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
/// <summary>The host row for a given machine, for the tests whose rows deliberately share a name.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="Host"/> cannot be asked when two rows are called the same thing, which is the whole
|
||||||
|
/// situation those tests set up. Re-found rather than held, because saving any host rebuilds the list.
|
||||||
|
/// </remarks>
|
||||||
|
private static HostRowViewModel Machine(VaultViewModel vault, string hostname) =>
|
||||||
|
vault.Hosts.Single(row => string.Equals(row.Host.Hostname, hostname, StringComparison.Ordinal));
|
||||||
|
|
||||||
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
|
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
using DodoSSH.Client.App.Platform;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One desktop instance per profile, and what the second launch does instead of starting.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Both halves are real here: a real lock file in a real temporary profile, and a real named pipe. Neither
|
||||||
|
/// is worth faking — the whole claim being made is about what the operating system does with an exclusive
|
||||||
|
/// handle and a pipe name, and a fake of either would be a test of the fake.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// What is <em>not</em> reachable from here is a second process. Every test below runs in one, so the lock
|
||||||
|
/// is proved against a second <em>handle</em> rather than a second executable, and the window that comes
|
||||||
|
/// forward is a delegate rather than a window. <c>docs/manual-checks.md</c> 16.11 is the other side of that
|
||||||
|
/// line.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SingleInstanceTests : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A profile of its own per test, which is what makes these safe to run beside a real installation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The pipe name is derived from this directory — see <c>SingleInstance.PipeNameFor</c> — so a unique
|
||||||
|
/// directory is also a unique pipe. Without that, a test here would ring the doorbell of whatever
|
||||||
|
/// DodoSSH the developer happens to have running.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly ClientPaths paths =
|
||||||
|
new(Path.Combine(Path.GetTempPath(), $"dodossh-instance-{Guid.CreateVersion7():N}"));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No patience at all, which is what every test here wants.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The shipped three seconds exist for a lock held by a process on its way out — an update relaunching
|
||||||
|
/// itself, above all — and nothing here is on its way anywhere. Passing zero takes the give-up decision
|
||||||
|
/// on the first attempt, so a refusal costs a millisecond rather than three seconds of the suite.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly TimeSpan Impatient = TimeSpan.Zero;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long a doorbell is given to ring before the test says it never will.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>A bound at all, which is the lesson rather than the number.</b> These two awaits were written
|
||||||
|
/// against <c>TestContext.Current.CancellationToken</c> alone, which carries no timeout — so the run
|
||||||
|
/// where a ring was genuinely lost did not fail, it <em>hung</em>, and took the whole CI job with it for
|
||||||
|
/// as long as anybody let it. Twenty seconds is a hundred times what a local pipe needs and a fifth of
|
||||||
|
/// what it costs to notice; a bound that is never reached costs nothing and a missing one costs the
|
||||||
|
/// build.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly TimeSpan LongEnoughForAPipe = TimeSpan.FromSeconds(20);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(paths.DataDirectory))
|
||||||
|
{
|
||||||
|
Directory.Delete(paths.DataDirectory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheFirstLaunchClaimsTheProfile()
|
||||||
|
{
|
||||||
|
using var claim = SingleInstance.Claim(paths, Impatient);
|
||||||
|
|
||||||
|
claim.ShouldNotBeNull();
|
||||||
|
File.Exists(paths.InstanceLockFile).ShouldBeTrue("the claim is a handle on a real file");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The point of the whole exercise. Nobody is listening in this test, so the second claim takes the
|
||||||
|
/// give-up path rather than the ask-and-leave one — which is why it is handed no patience at all.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ASecondLaunchIsRefused()
|
||||||
|
{
|
||||||
|
using var first = SingleInstance.Claim(paths, Impatient);
|
||||||
|
first.ShouldNotBeNull();
|
||||||
|
|
||||||
|
SingleInstance.Claim(paths, Impatient).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ There is no such thing as a stale lock here, and this is what says so. The handle is closed by the
|
||||||
|
/// runtime when it is disposed and by the kernel when the process ends however it ends — which is the
|
||||||
|
/// reason this is a file rather than a named mutex, whose abandonment is a state to be handled.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void TheProfileIsClaimableAgainOnceTheFirstInstanceLetsGo()
|
||||||
|
{
|
||||||
|
var first = SingleInstance.Claim(paths, Impatient);
|
||||||
|
first.ShouldNotBeNull();
|
||||||
|
first.Dispose();
|
||||||
|
|
||||||
|
using var second = SingleInstance.Claim(paths, Impatient);
|
||||||
|
|
||||||
|
second.ShouldNotBeNull("the lock went with the handle");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other half: a refused launch is not a silent one. Somebody who double-clicks the icon and sees
|
||||||
|
/// nothing has been told the application is broken, so the running instance is asked to come forward.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ARefusedLaunchAsksTheRunningInstanceToComeForward()
|
||||||
|
{
|
||||||
|
using var first = SingleInstance.Claim(paths, Impatient);
|
||||||
|
first.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var asked = new TaskCompletionSource();
|
||||||
|
|
||||||
|
await using var doorbell = SingleInstance.Listen(paths, () => asked.TrySetResult());
|
||||||
|
|
||||||
|
SingleInstance.Claim(paths, Impatient).ShouldBeNull("the profile is taken");
|
||||||
|
|
||||||
|
// Awaited rather than polled: the callback comes off a thread pool thread. Bounded explicitly,
|
||||||
|
// because the test's own token is not — see LongEnoughForAPipe.
|
||||||
|
await asked.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A third and a fourth launch work as well as the second, and this is the test that caught the doorbell
|
||||||
|
/// getting that wrong on Linux and only on Linux. A fresh <c>NamedPipeServerStream</c> per connection
|
||||||
|
/// closes the listening socket between callers there, so the second ring was accepted by nobody while
|
||||||
|
/// the launch that made it was told it had succeeded. See <c>SingleInstance.Doorbell</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Two rings back to back rather than one and a pause, because the gap is the whole defect: a test that
|
||||||
|
/// waited for the first to be answered before making the second would pass against the broken version.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheDoorbellAnswersMoreThanOnce()
|
||||||
|
{
|
||||||
|
using var first = SingleInstance.Claim(paths, Impatient);
|
||||||
|
first.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var rings = 0;
|
||||||
|
var twice = new TaskCompletionSource();
|
||||||
|
|
||||||
|
await using var doorbell = SingleInstance.Listen(
|
||||||
|
paths,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (Interlocked.Increment(ref rings) == 2)
|
||||||
|
{
|
||||||
|
twice.TrySetResult();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
|
||||||
|
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
|
||||||
|
|
||||||
|
await twice.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// What the give-up path turns on. A lock held by something that is not listening — a process still
|
||||||
|
/// starting, or one on its way out — answers no, and that is what makes <c>Claim</c> wait and try the
|
||||||
|
/// lock again rather than concluding anything from the silence.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void AskingWithNobodyListening_SaysSo()
|
||||||
|
{
|
||||||
|
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Two profiles, because a nightly and a release build are installed side by side on purpose and must
|
||||||
|
/// not exclude each other — ADR 0013. The lock file is inside the profile and the pipe name is derived
|
||||||
|
/// from it, so both halves are scoped by the one decision <c>ClientPaths.ForChannel</c> already made.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void TwoProfilesDoNotExcludeEachOther()
|
||||||
|
{
|
||||||
|
var other = new ClientPaths(paths.DataDirectory + ".Nightly");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var release = SingleInstance.Claim(paths, Impatient);
|
||||||
|
using var nightly = SingleInstance.Claim(other, Impatient);
|
||||||
|
|
||||||
|
release.ShouldNotBeNull();
|
||||||
|
nightly.ShouldNotBeNull("a second profile is a second application");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(other.DataDirectory))
|
||||||
|
{
|
||||||
|
Directory.Delete(other.DataDirectory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -390,8 +390,14 @@ public sealed class UpdateFlowTests : IDisposable
|
|||||||
updates.State.ShouldBe(UpdateState.Ready);
|
updates.State.ShouldBe(UpdateState.Ready);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Asks, and does not fetch again. The distinction is the whole of the fix for a client that installed
|
||||||
|
/// two updates to move one step: the old guard skipped the <em>pass</em> once anything was ready, so the
|
||||||
|
/// banner was pinned to whatever build the process happened to see first. Not re-fetching is still the
|
||||||
|
/// rule; it is enforced by comparing versions now rather than by refusing to look.
|
||||||
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task APassWhileOneIsAlreadyReady_DoesNotAskAgain()
|
public async Task APassWhileOneIsAlreadyReady_AsksAgainAndDoesNotFetchTwice()
|
||||||
{
|
{
|
||||||
channel.Available = new AvailableUpdate("1.3.0");
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
@@ -400,7 +406,181 @@ public sealed class UpdateFlowTests : IDisposable
|
|||||||
await updates.CheckOnceAsync(Token);
|
await updates.CheckOnceAsync(Token);
|
||||||
await updates.CheckOnceAsync(Token);
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
channel.Checks.ShouldBe(1);
|
channel.Checks.ShouldBe(2);
|
||||||
|
channel.Downloads.ShouldBe(1, "the second pass found the build it had already fetched");
|
||||||
|
updates.State.ShouldBe(UpdateState.Ready);
|
||||||
|
updates.ReadyVersion.ShouldBe("1.3.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ The regression this whole change is about, in the case the timer can reach. A rolling nightly
|
||||||
|
/// channel keeps exactly one build, so a client left running does not accumulate a queue of updates — it
|
||||||
|
/// is offered whichever one the feed holds, and the pending build has to be replaced when that moves on.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task APassWhileOneIsAlreadyReady_ReplacesItWhenTheFeedHasMovedOn()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
updates.ReadyVersion.ShouldBe("1.3.0");
|
||||||
|
|
||||||
|
channel.Available = new AvailableUpdate("1.4.0");
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
channel.Downloads.ShouldBe(2);
|
||||||
|
updates.State.ShouldBe(UpdateState.Ready);
|
||||||
|
updates.ReadyVersion.ShouldBe("1.4.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A build superseded after LATER was pressed brings the banner back, because LATER was an answer about
|
||||||
|
/// a version and this is a different one.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ASupersededOffer_IsShownAgainEvenIfTheOldOneWasDismissed()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
updates.DismissBannerCommand.Execute(null);
|
||||||
|
updates.IsBannerShowing.ShouldBeFalse();
|
||||||
|
|
||||||
|
channel.Available = new AvailableUpdate("1.4.0");
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
updates.IsBannerShowing.ShouldBeTrue();
|
||||||
|
updates.ReadyHeadline.ShouldContain("1.4.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ The half the timer cannot reach, and the one a person actually hit: a build published between the
|
||||||
|
/// last pass and the press. Without this the press installs a build that was already superseded, and the
|
||||||
|
/// next launch offers the newer one straight away — two downloads, two installs and two restarts to move
|
||||||
|
/// one step.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task Restarting_TakesOneLastLookAndInstallsWhatItFinds()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
// Published while the banner was up, which on a rolling channel replaces the release rather than
|
||||||
|
// adding to it — so the feed now holds only this one.
|
||||||
|
channel.Available = new AvailableUpdate("1.4.0");
|
||||||
|
|
||||||
|
await updates.RestartNowCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
channel.Restarts.ShouldBe(1);
|
||||||
|
channel.RestartedWith!.Version.ShouldBe("1.4.0");
|
||||||
|
restarts.ShouldBe(1, "one restart, not one per build");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The ordinary case, and what it costs: one question and no download. A press that re-fetched what was
|
||||||
|
/// already on disk would have made the fix worse than the bug.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task Restarting_AsksOnceMoreAndFetchesNothingWhenNothingIsNewer()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
await updates.RestartNowCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
channel.Checks.ShouldBe(2);
|
||||||
|
channel.Downloads.ShouldBe(1);
|
||||||
|
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The look is a courtesy and never a gate. Somebody pressing RESTART NOW on a train has a build on disk
|
||||||
|
/// that is newer than the one running, and a forge that cannot be reached must not stand between them.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task RestartingWhenTheLastLookFails_StillInstallsWhatWasAlreadyFetched()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
channel.CheckFailure = new HttpRequestException("no such host");
|
||||||
|
|
||||||
|
await updates.RestartNowCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
channel.Restarts.ShouldBe(1);
|
||||||
|
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
||||||
|
updates.State.ShouldNotBe(UpdateState.Failed, "nothing the user asked for failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="RestartingWhenTheLastLookFails_StillInstallsWhatWasAlreadyFetched" />
|
||||||
|
/// <remarks>
|
||||||
|
/// The same rule one step further in: the newer build was found and then could not be fetched. What is
|
||||||
|
/// on disk is untouched, so that is what gets installed.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task RestartingWhenTheNewerFetchFails_StillInstallsWhatWasAlreadyFetched()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
channel.Available = new AvailableUpdate("1.4.0");
|
||||||
|
channel.DownloadFailure = new HttpRequestException("the connection was reset");
|
||||||
|
|
||||||
|
await updates.RestartNowCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
channel.Restarts.ShouldBe(1);
|
||||||
|
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything the banner draws keys off <c>IsReady</c>, and the last look moves the state off it — so
|
||||||
|
/// without <c>IsRestarting</c> the strip vanished the moment RESTART NOW was pressed and left nothing on
|
||||||
|
/// screen saying why, for as long as a package takes to fetch.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheBannerStaysUpWhileARestartFetchesANewerBuild()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
channel.Available = new AvailableUpdate("1.4.0");
|
||||||
|
channel.HoldDownload = new TaskCompletionSource();
|
||||||
|
|
||||||
|
var pressed = updates.RestartNowCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
// The gate is what makes the intermediate state observable; without it the fetch is over before
|
||||||
|
// anything could look at it.
|
||||||
|
while (updates.State is not UpdateState.Downloading)
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.IsBannerShowing.ShouldBeTrue();
|
||||||
|
updates.ReadyHeadline.ShouldContain("before restarting");
|
||||||
|
|
||||||
|
channel.HoldDownload.SetResult();
|
||||||
|
await pressed;
|
||||||
|
|
||||||
|
channel.RestartedWith!.Version.ShouldBe("1.4.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The ssh_config reader, against strings. No disk, because the parser takes its include reader as a
|
Both readers, each against a stand-in for the world it reads.
|
||||||
|
|
||||||
|
The ssh_config one runs against strings. No disk, because the parser takes its include reader as a
|
||||||
parameter — which is what lets Include, the one directive whose behaviour is a file-system behaviour,
|
parameter — which is what lets Include, the one directive whose behaviour is a file-system behaviour,
|
||||||
be covered by ordinary unit tests.
|
be covered by ordinary unit tests.
|
||||||
|
|
||||||
|
The Hetzner one runs against a stubbed HttpMessageHandler, which is the same trade: no account, no token
|
||||||
|
and no network, and every question about what the client decides from what Hetzner said still answerable.
|
||||||
|
What that leaves for a person is in docs/manual-checks.md, 2.11a onwards.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Import;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Import.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reading a Hetzner Cloud project, against a stubbed transport.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// No socket, for the reason <c>LinuxUpdateChannelTests</c> gives: every question here is what this client
|
||||||
|
/// decides from what Hetzner said, and a real request would add an account, a token and a network to the
|
||||||
|
/// assertions without adding anything to what is being checked.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The bodies are real shapes rather than minimal ones. An importer's whole value is agreeing with the
|
||||||
|
/// source about what it means — the IPv6 field holding a network rather than an address, and a server that
|
||||||
|
/// has no IPv4 at all, are both ordinary in a Hetzner project and both are exactly where an importer that
|
||||||
|
/// was nearly right would produce bookmarks that nearly connect.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HetznerCloudClientTests
|
||||||
|
{
|
||||||
|
private const string Token = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||||
|
|
||||||
|
private static CancellationToken Cancellation => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APageOfServers_ArrivesWithWhatTheProjectSaidAboutThem()
|
||||||
|
{
|
||||||
|
var import = await ReadAsync(OnePage(OneServer));
|
||||||
|
|
||||||
|
var server = import.Servers.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
server.Id.ShouldBe(42);
|
||||||
|
server.Name.ShouldBe("web-01");
|
||||||
|
server.Status.ShouldBe("running");
|
||||||
|
server.PublicIPv4.ShouldBe("203.0.113.10");
|
||||||
|
server.ServerType.ShouldBe("cx22");
|
||||||
|
server.Location.ShouldBe("fsn1 · Falkenstein, DE");
|
||||||
|
server.Image.ShouldBe("ubuntu-24.04");
|
||||||
|
server.Labels.ShouldBe(["env=prod", "role=web"]);
|
||||||
|
import.Warnings.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The token goes in an <c>Authorization: Bearer</c> header and nowhere else — never a query parameter,
|
||||||
|
/// which is the shape that ends up in somebody's proxy log.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTokenTravelsAsABearerHeader()
|
||||||
|
{
|
||||||
|
var stub = new StubHetzner([OnePage(OneServer)]);
|
||||||
|
|
||||||
|
await new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation);
|
||||||
|
|
||||||
|
var request = stub.Requests.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
request.Authorization.ShouldBe($"Bearer {Token}");
|
||||||
|
request.Uri.ShouldNotContain(Token);
|
||||||
|
request.Uri.ShouldContain("per_page=50");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every page is followed, which is what keeps a short list from looking like a complete one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Fifty-one servers in a project is enough to meet this, and an importer that showed the first fifty
|
||||||
|
/// would present a list nobody has any reason to doubt.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task EveryPageIsFollowed()
|
||||||
|
{
|
||||||
|
var stub = new StubHetzner([
|
||||||
|
Page(nextPage: 2, """{ "id": 1, "name": "web-01" }"""),
|
||||||
|
Page(nextPage: 3, """{ "id": 2, "name": "web-02" }"""),
|
||||||
|
Page(nextPage: null, """{ "id": 3, "name": "web-03" }"""),
|
||||||
|
]);
|
||||||
|
|
||||||
|
var import = await new HetznerCloudClient(new HttpClient(stub))
|
||||||
|
.ListServersAsync(Token, Cancellation);
|
||||||
|
|
||||||
|
import.Servers.Select(server => server.Name).ShouldBe(["web-01", "web-02", "web-03"]);
|
||||||
|
stub.Requests.Select(request => request.Uri).ShouldAllBe(uri => uri.Contains("page="));
|
||||||
|
stub.Requests.Count.ShouldBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A refused token is the failure this wizard meets most, because the value is shown once in the Hetzner
|
||||||
|
/// console and a half-copied one fails exactly like a wrong one. So the message says where tokens come
|
||||||
|
/// from, and <c>IsTokenProblem</c> is what sends the wizard back to the step that has the box.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ARefusedToken_SaysWhereTokensComeFromAndIsMarkedAsTheTokensFault()
|
||||||
|
{
|
||||||
|
var stub = new StubHetzner([
|
||||||
|
new StubResponse(
|
||||||
|
HttpStatusCode.Unauthorized,
|
||||||
|
"""{ "error": { "code": "unauthorized", "message": "unable to authenticate" } }"""),
|
||||||
|
]);
|
||||||
|
|
||||||
|
var failure = await Should.ThrowAsync<HetznerCloudException>(
|
||||||
|
() => new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation));
|
||||||
|
|
||||||
|
failure.IsTokenProblem.ShouldBeTrue();
|
||||||
|
failure.Code.ShouldBe("unauthorized");
|
||||||
|
failure.Message.ShouldContain("API tokens");
|
||||||
|
failure.Message.ShouldContain("unable to authenticate", Case.Insensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other half of that split: Hetzner having a bad minute is not a reason to send somebody back to
|
||||||
|
/// re-type a token that was fine.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AFaultAtHetznersEnd_IsNotTheTokensFault()
|
||||||
|
{
|
||||||
|
var stub = new StubHetzner([new StubResponse(HttpStatusCode.BadGateway, "<html>nope</html>")]);
|
||||||
|
|
||||||
|
var failure = await Should.ThrowAsync<HetznerCloudException>(
|
||||||
|
() => new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation));
|
||||||
|
|
||||||
|
failure.IsTokenProblem.ShouldBeFalse();
|
||||||
|
failure.Message.ShouldContain("502");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Refused before anything is sent, because a header value carrying a line break throws from inside
|
||||||
|
/// <c>HttpClient</c> — a stack trace where the honest answer is a sentence.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ATokenWithALineBreakInTheMiddle_IsRefusedWithoutAskingHetzner()
|
||||||
|
{
|
||||||
|
var stub = new StubHetzner([OnePage(OneServer)]);
|
||||||
|
|
||||||
|
var failure = await Should.ThrowAsync<HetznerCloudException>(
|
||||||
|
() => new HetznerCloudClient(new HttpClient(stub))
|
||||||
|
.ListServersAsync("abcdef\n0123456789", Cancellation));
|
||||||
|
|
||||||
|
failure.Message.ShouldContain("64 letters and digits");
|
||||||
|
stub.Requests.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>Pasting out of the console brings a trailing newline often enough to be worth handling.</remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ATokenPastedWithSurroundingWhitespace_IsTrimmedRatherThanRefused()
|
||||||
|
{
|
||||||
|
var stub = new StubHetzner([OnePage(OneServer)]);
|
||||||
|
|
||||||
|
await new HetznerCloudClient(new HttpClient(stub)).ListServersAsync($" {Token}\n", Cancellation);
|
||||||
|
|
||||||
|
stub.Requests.ShouldHaveSingleItem().Authorization.ShouldBe($"Bearer {Token}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A server with no IPv4 at all, which is a whole Hetzner price tier rather than an edge case.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Two things are being checked and the second is the one that matters. The address is the first in the
|
||||||
|
/// routed /64, because what the API reports is a network and nothing can dial a network. And it carries
|
||||||
|
/// a warning, because that address is a convention of Hetzner's own images rather than a fact the API
|
||||||
|
/// stated — an import that silently guessed would produce a bookmark that fails to connect with no
|
||||||
|
/// indication of why.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AServerWithNoIPv4_DialsTheFirstAddressOfItsSixtyFourAndSaysSo()
|
||||||
|
{
|
||||||
|
var import = await ReadAsync(OnePage("""
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"name": "v6-only",
|
||||||
|
"status": "running",
|
||||||
|
"public_net": {
|
||||||
|
"ipv4": null,
|
||||||
|
"ipv6": { "ip": "2a01:4f8:1c17:d00::/64" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
var server = import.Servers.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
server.HasPublicIPv4.ShouldBeFalse();
|
||||||
|
server.FirstIPv6Address.ShouldBe("2a01:4f8:1c17:d00::1");
|
||||||
|
|
||||||
|
var address = server.Resolve(HetznerAddressKind.PublicIPv4).ShouldNotBeNull();
|
||||||
|
|
||||||
|
address.Value.ShouldBe("2a01:4f8:1c17:d00::1");
|
||||||
|
address.Warning.ShouldNotBeNull().ShouldContain("No public IPv4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The v6 choice warns even where it is the one that was asked for, on the same reasoning: the address
|
||||||
|
/// is inferred from a network either way.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ChoosingIPv6_StillSaysTheAddressWasInferredFromANetwork()
|
||||||
|
{
|
||||||
|
var import = await ReadAsync(OnePage(OneServer));
|
||||||
|
|
||||||
|
var address = import.Servers[0].Resolve(HetznerAddressKind.PublicIPv6).ShouldNotBeNull();
|
||||||
|
|
||||||
|
address.Value.ShouldBe("2a01:4f8:1c17:d00::1");
|
||||||
|
address.Warning.ShouldNotBeNull().ShouldContain("2a01:4f8:1c17:d00::/64");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The private choice does not fall back, and that asymmetry is the point.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Falling back between v4 and v6 reaches the same machine over the same internet. Falling back from a
|
||||||
|
/// private address to a public one would put an address on the open internet into a bookmark that was
|
||||||
|
/// asked to stay off it, so a server with no private network is simply not importable under that choice.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AServerOnNoPrivateNetwork_HasNoPrivateAddressToOffer()
|
||||||
|
{
|
||||||
|
var import = await ReadAsync(OnePage(OneServer));
|
||||||
|
|
||||||
|
import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AServerOnAPrivateNetwork_DialsItWithoutAWarning()
|
||||||
|
{
|
||||||
|
var import = await ReadAsync(OnePage("""
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"name": "db-01",
|
||||||
|
"private_net": [ { "ip": "10.0.0.4" } ]
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
var address = import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldNotBeNull();
|
||||||
|
|
||||||
|
address.Value.ShouldBe("10.0.0.4");
|
||||||
|
address.Warning.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Two private networks is a real shape and the first one is a choice this makes rather than a fact, so
|
||||||
|
/// it is said out loud — the same rule the IPv6 address follows.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AServerOnTwoPrivateNetworks_TakesTheFirstAndSaysThereWereTwo()
|
||||||
|
{
|
||||||
|
var import = await ReadAsync(OnePage("""
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"name": "db-01",
|
||||||
|
"private_net": [ { "ip": "10.0.0.4" }, { "ip": "10.1.0.4" } ]
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
var address = import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldNotBeNull();
|
||||||
|
|
||||||
|
address.Value.ShouldBe("10.0.0.4");
|
||||||
|
address.Warning.ShouldNotBeNull().ShouldContain("2 private networks");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What is stored, and what is deliberately not.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The note keeps what does not go stale — the id, the plan, where it runs, what it was built from. The
|
||||||
|
/// power state is not in it: a note saying "running" about a machine somebody switched off last March is
|
||||||
|
/// worse than no note, so status is a column on the import screen and never a stored fact.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task WhatIsStored_CarriesTheProjectsContextAndNotItsPowerState()
|
||||||
|
{
|
||||||
|
var import = await ReadAsync(OnePage(OneServer));
|
||||||
|
var server = import.Servers[0];
|
||||||
|
|
||||||
|
var host = server.ToSecret(
|
||||||
|
server.Resolve(HetznerAddressKind.PublicIPv4).ShouldNotBeNull(),
|
||||||
|
"root",
|
||||||
|
22);
|
||||||
|
|
||||||
|
host.Label.ShouldBe("web-01");
|
||||||
|
host.Hostname.ShouldBe("203.0.113.10");
|
||||||
|
host.Username.ShouldBe("root");
|
||||||
|
host.Port.ShouldBe(22);
|
||||||
|
host.SshKeyId.ShouldBeNull("the private half of a key was never Hetzner's to hand over");
|
||||||
|
|
||||||
|
var notes = host.Notes.ShouldNotBeNull();
|
||||||
|
|
||||||
|
notes.ShouldContain("server 42");
|
||||||
|
notes.ShouldContain("cx22");
|
||||||
|
notes.ShouldContain("fsn1 · Falkenstein, DE");
|
||||||
|
notes.ShouldContain("ubuntu-24.04");
|
||||||
|
notes.ShouldContain("env=prod");
|
||||||
|
notes.ShouldNotContain("running");
|
||||||
|
|
||||||
|
host.TryValidate(out _).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A blank login user stores none rather than storing an empty one, which is what the ssh_config import
|
||||||
|
/// does for an entry that named no <c>User</c>.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ABlankLoginUser_StoresNoUsername()
|
||||||
|
{
|
||||||
|
var import = await ReadAsync(OnePage(OneServer));
|
||||||
|
var server = import.Servers[0];
|
||||||
|
|
||||||
|
server.ToSecret(server.Resolve(HetznerAddressKind.PublicIPv4)!, " ", 22).Username.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A server with no name would arrive as a bookmark called nothing, which <c>HostSecret.TryValidate</c>
|
||||||
|
/// refuses outright — so it is named for its id and the project-level warning says one was.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AServerWithNoName_IsOfferedUnderItsIdAndWarnedAbout()
|
||||||
|
{
|
||||||
|
var import = await ReadAsync(OnePage("""{ "id": 512, "status": "off" }"""));
|
||||||
|
|
||||||
|
import.Servers.ShouldHaveSingleItem().Name.ShouldBe("server-512");
|
||||||
|
import.Warnings.ShouldHaveSingleItem().ShouldContain("server-512");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything but the id is optional in practice — a server built from a snapshot has no image, and one
|
||||||
|
/// mid-creation has no datacentre yet. None of that is a reason to refuse the whole page.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AServerTheApiDescribedSparsely_StillImports()
|
||||||
|
{
|
||||||
|
var import = await ReadAsync(OnePage("""{ "id": 3, "name": "fresh", "status": "initializing" }"""));
|
||||||
|
|
||||||
|
var server = import.Servers.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
server.ServerType.ShouldBeNull();
|
||||||
|
server.Location.ShouldBeNull();
|
||||||
|
server.Image.ShouldBeNull();
|
||||||
|
server.Labels.ShouldBeEmpty();
|
||||||
|
server.Resolve(HetznerAddressKind.PublicIPv4).ShouldBeNull("it has no address of any kind yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
/// <summary>A server carrying everything the API can say about one, used wherever the shape is not the point.</summary>
|
||||||
|
private const string OneServer = """
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"name": "web-01",
|
||||||
|
"status": "running",
|
||||||
|
"public_net": {
|
||||||
|
"ipv4": { "id": 1, "ip": "203.0.113.10", "blocked": false },
|
||||||
|
"ipv6": { "id": 2, "ip": "2a01:4f8:1c17:d00::/64", "blocked": false }
|
||||||
|
},
|
||||||
|
"private_net": [],
|
||||||
|
"server_type": { "id": 22, "name": "cx22", "cores": 2 },
|
||||||
|
"datacenter": {
|
||||||
|
"id": 4,
|
||||||
|
"name": "fsn1-dc14",
|
||||||
|
"location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" }
|
||||||
|
},
|
||||||
|
"image": { "id": 100, "name": "ubuntu-24.04", "os_flavor": "ubuntu" },
|
||||||
|
"labels": { "role": "web", "env": "prod" }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static Task<HetznerImport> ReadAsync(StubResponse page) =>
|
||||||
|
new HetznerCloudClient(new HttpClient(new StubHetzner([page]))).ListServersAsync(Token, Cancellation);
|
||||||
|
|
||||||
|
private static StubResponse OnePage(string server) => Page(nextPage: null, server);
|
||||||
|
|
||||||
|
private static StubResponse Page(int? nextPage, string server) =>
|
||||||
|
new(
|
||||||
|
HttpStatusCode.OK,
|
||||||
|
$$"""
|
||||||
|
{
|
||||||
|
"servers": [ {{server}} ],
|
||||||
|
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": {{Next(nextPage)}} } }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
private static string Next(int? page) =>
|
||||||
|
page is { } value ? value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "null";
|
||||||
|
|
||||||
|
private sealed record StubResponse(HttpStatusCode Status, string Body);
|
||||||
|
|
||||||
|
private sealed record SeenRequest(string Uri, string? Authorization);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Answers each request with the next scripted response, and remembers what was asked.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Scripted in order rather than keyed by URL, because the thing being checked in the paging test is the
|
||||||
|
/// sequence: a client that asked for page one three times would pass a lookup by address and produce the
|
||||||
|
/// same list three times over.
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class StubHetzner(IReadOnlyList<StubResponse> responses) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private int served;
|
||||||
|
|
||||||
|
internal List<SeenRequest> Requests { get; } = [];
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Requests.Add(new SeenRequest(
|
||||||
|
request.RequestUri!.AbsoluteUri,
|
||||||
|
request.Headers.Authorization?.ToString()));
|
||||||
|
|
||||||
|
var scripted = responses[Math.Min(served++, responses.Count - 1)];
|
||||||
|
|
||||||
|
return Task.FromResult(new HttpResponseMessage(scripted.Status)
|
||||||
|
{
|
||||||
|
Content = new StringContent(scripted.Body, Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,6 +162,84 @@ public sealed class PumpOverRealSshTests(SshServerFixture fixture)
|
|||||||
"the strip under the terminal prints this sentence, so an empty one is a blank status line");
|
"the strip under the terminal prints this sentence, so an empty one is a blank status line");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A remote flooding a renderer that never draws anything is bounded, and the pump keeps reading.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ THIS IS THE TEST THE OLD DESIGN WOULD FAIL, AND IT NEEDS A REAL SSH.NET UNDERNEATH IT TO SAY SO.
|
||||||
|
/// The pump used to stop reading once a renderer had a credit window's worth of unrendered output, on
|
||||||
|
/// the reasoning that SSH's own receive window would then close and block the remote. It does not:
|
||||||
|
/// <c>Renci.SshNet.Channels.Channel.OnData</c> adjusts the window as bytes <em>arrive</em>, before it
|
||||||
|
/// hands them on, so the remote goes on sending at full speed and <c>ShellStream</c> copies every byte
|
||||||
|
/// into a buffer that doubles and is never given back. Against this test, <c>yes</c> would have been
|
||||||
|
/// read at <c>BytesRead == WindowBytes</c> exactly and then stopped for ever while the process grew.
|
||||||
|
/// A fake shell cannot show that, because a fake hands over bytes when it is asked and a real channel
|
||||||
|
/// hands them over when they arrive.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// So the assertion is in two halves and needs both. Reading keeps going, which is what keeps SSH.NET's
|
||||||
|
/// buffer drained; and what this side holds stays inside the backlog, which is what keeps the drained
|
||||||
|
/// bytes from simply piling up here instead. Either alone is satisfied by a design that leaks.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog()
|
||||||
|
{
|
||||||
|
const int Window = 64 * 1024;
|
||||||
|
const int Backlog = 512 * 1024;
|
||||||
|
|
||||||
|
await using var connection = await ConnectTrustedAsync();
|
||||||
|
|
||||||
|
var shell = await connection.OpenShellAsync(
|
||||||
|
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Deliberately never acknowledges: this is a renderer that has stopped drawing, which is the whole
|
||||||
|
// condition under examination. CountingTransport only acknowledges when it is given a pump.
|
||||||
|
var transport = new CountingTransport();
|
||||||
|
|
||||||
|
await using var pump = new TerminalSessionPump(
|
||||||
|
1,
|
||||||
|
shell,
|
||||||
|
transport,
|
||||||
|
TimeProvider.System,
|
||||||
|
new TerminalPumpOptions
|
||||||
|
{
|
||||||
|
FlushInterval = TimeSpan.FromMilliseconds(20),
|
||||||
|
WindowBytes = Window,
|
||||||
|
BacklogBytes = Backlog,
|
||||||
|
});
|
||||||
|
|
||||||
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await pump.WriteInputAsync("yes dodossh\n"u8.ToArray(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Long enough that a pump which had stopped at the window would plainly have stopped, and that one
|
||||||
|
// which had not would have read many times the backlog's capacity.
|
||||||
|
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
while (TimeProvider.System.GetUtcNow() < deadline && pump.BytesRead < Backlog * 4L)
|
||||||
|
{
|
||||||
|
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
pump.BytesPending.ShouldBeLessThanOrEqualTo(
|
||||||
|
Backlog,
|
||||||
|
"output is accumulating on this side of the socket, which is the growth the backlog bounds");
|
||||||
|
}
|
||||||
|
|
||||||
|
pump.BytesRead.ShouldBeGreaterThan(
|
||||||
|
Backlog * 4L,
|
||||||
|
"the pump stopped reading, so the flood is piling up inside SSH.NET where nothing bounds it");
|
||||||
|
|
||||||
|
pump.BytesDropped.ShouldBeGreaterThan(
|
||||||
|
0,
|
||||||
|
"nothing was dropped, so something between here and the screen is still holding all of it");
|
||||||
|
|
||||||
|
await pump.DisposeAsync();
|
||||||
|
|
||||||
|
await Should.NotThrowAsync(() => run.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Trusts the relay's identity, then connects through it.</summary>
|
/// <summary>Trusts the relay's identity, then connects through it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Pinned under the relay's own address, which is the honest thing here: what is under examination is a
|
/// Pinned under the relay's own address, which is the honest thing here: what is under examination is a
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Ssh.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The probe that runs after a connection has failed, and the four answers it can come back with.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Real sockets on loopback rather than an abstraction over them, because the thing under test <em>is</em>
|
||||||
|
/// what a socket does: the whole reason this type exists is that a refusal and a silence are
|
||||||
|
/// indistinguishable in the exception SSH.NET throws and completely distinguishable to a TCP connect. A fake
|
||||||
|
/// socket would be this suite asserting that the mapping table maps, and would have passed just as happily
|
||||||
|
/// against the bug that prompted the work.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// No container and no sshd. Nothing here speaks SSH — the probe deliberately does not — so the fixture is a
|
||||||
|
/// listener that accepts, a port nothing is on, and a name that does not resolve.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SshReachabilityProbeTests
|
||||||
|
{
|
||||||
|
/// <remarks>
|
||||||
|
/// Short, because two of these cases prove a negative by waiting. The production budget is six seconds —
|
||||||
|
/// see <see cref="SshReachabilityProbe.Budget"/> — and nothing about the branch under test changes with
|
||||||
|
/// the number, so the suite buys the same coverage for a fraction of the wall clock.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly TimeSpan ShortBudget = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The case the whole type was written for. SSH.NET reports this and <see cref="NothingListening"/> with
|
||||||
|
/// the identical "Connection has timed out." — so if this comes back as anything but
|
||||||
|
/// <see cref="SshReach.Open"/>, the probe is adding nothing the exception did not already say.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task APortThatAcceptsIsOpen_WhichIsWhatTellsAWedgedSshdFromABrokenNetwork()
|
||||||
|
{
|
||||||
|
using var listener = Accepting();
|
||||||
|
|
||||||
|
var report = await new SshReachabilityProbe(ShortBudget)
|
||||||
|
.ProbeAsync("127.0.0.1", PortOf(listener), Token);
|
||||||
|
|
||||||
|
report.Reach.ShouldBe(SshReach.Open);
|
||||||
|
report.Address.ShouldBe("127.0.0.1");
|
||||||
|
report.Summary.ShouldContain("handshake");
|
||||||
|
|
||||||
|
// The half a user acts on: nothing here may suggest the network is at fault, because it demonstrably
|
||||||
|
// is not — a socket just opened over it.
|
||||||
|
report.Summary.ShouldNotContain("firewall");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A port with nothing on it. The distinction from <see cref="NothingAnswers"/> is the entire value of
|
||||||
|
/// the finding: refused means the machine is up.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The full budget, and this test is why the production one is six seconds.</b> A refusal is not
|
||||||
|
/// immediate on Windows — the stack retries the SYN, and a closed loopback port took ~2s to answer when
|
||||||
|
/// this was written. Run against <see cref="ShortBudget"/> this case comes back
|
||||||
|
/// <see cref="SshReach.NoAnswer"/>, which is the probe telling somebody a machine that is up and
|
||||||
|
/// refusing is behind a firewall. Left at the real budget on purpose: it is the one case whose
|
||||||
|
/// correctness depends on the number, so it is the one that should fail if the number is lowered.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task NothingListening()
|
||||||
|
{
|
||||||
|
var report = await new SshReachabilityProbe(SshReachabilityProbe.Budget)
|
||||||
|
.ProbeAsync("127.0.0.1", UnusedPort(), Token);
|
||||||
|
|
||||||
|
report.Reach.ShouldBe(SshReach.Refused);
|
||||||
|
report.Summary.ShouldContain("sshd is not running");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 192.0.2.1 is TEST-NET-1 from RFC 5737: reserved for documentation, routed nowhere, and guaranteed not
|
||||||
|
/// to be some real machine that answers and makes this flap. The packets go out and nothing comes back,
|
||||||
|
/// which is exactly the shape of a firewall that drops rather than rejects.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The elapsed assertion is the one that matters beside the enum. Silence is only evidence if the probe
|
||||||
|
/// actually waited for it, and a bug that returned <see cref="SshReach.NoAnswer"/> instantly — a socket
|
||||||
|
/// erroring for some unrelated reason — would otherwise pass this while telling the user something false
|
||||||
|
/// about their firewall.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task NothingAnswers()
|
||||||
|
{
|
||||||
|
var report = await new SshReachabilityProbe(ShortBudget).ProbeAsync("192.0.2.1", 22, Token);
|
||||||
|
|
||||||
|
report.Reach.ShouldBe(SshReach.NoAnswer);
|
||||||
|
report.Elapsed.ShouldBeGreaterThan(TimeSpan.FromSeconds(1));
|
||||||
|
report.Summary.ShouldContain("firewall");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>.invalid</c> is reserved by RFC 2606 precisely so that a test may rely on it never resolving, on
|
||||||
|
/// any network, including one whose resolver answers every NXDOMAIN with a search page.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ANameThatDoesNotResolve()
|
||||||
|
{
|
||||||
|
var report = await new SshReachabilityProbe(ShortBudget)
|
||||||
|
.ProbeAsync("dodossh-no-such-host.invalid", 22, Token);
|
||||||
|
|
||||||
|
report.Reach.ShouldBe(SshReach.Unresolved);
|
||||||
|
report.Address.ShouldBeNull("there is no address, and inventing one would be the whole failure");
|
||||||
|
report.Summary.ShouldContain("does not resolve");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// An address typed in is not looked up, and the report must not read as though it had been. The
|
||||||
|
/// assertion is on the sentence rather than on a call count: what would be wrong is the user being shown
|
||||||
|
/// "127.0.0.1 (127.0.0.1)", which is what a probe that resolved a literal and then printed both produces.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnAddressIsNotPrintedTwice()
|
||||||
|
{
|
||||||
|
using var listener = Accepting();
|
||||||
|
|
||||||
|
var report = await new SshReachabilityProbe(ShortBudget)
|
||||||
|
.ProbeAsync("127.0.0.1", PortOf(listener), Token);
|
||||||
|
|
||||||
|
report.Summary.ShouldNotContain("(127.0.0.1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The caller is in a failure path with nothing to do with a second exception — see
|
||||||
|
/// <see cref="ISshReachabilityProbe.ProbeAsync"/> — so every branch above returns a report. This is the
|
||||||
|
/// one input that is a caller's mistake rather than a broken host, and it is the one that still throws.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEmptyHostIsTheCallersBug_NotAFinding()
|
||||||
|
{
|
||||||
|
await Should.ThrowAsync<ArgumentException>(
|
||||||
|
async () => await new SshReachabilityProbe(ShortBudget).ProbeAsync(" ", 22, Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TcpListener Accepting()
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
return listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int PortOf(TcpListener listener) => ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
|
||||||
|
/// <summary>A port that was free a moment ago, which is the closest thing to a guarantee there is.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Bound and released rather than picked out of the air: the OS hands out a port it knows is unused, and
|
||||||
|
/// the window in which something else could take it is far smaller than the odds of a hard-coded number
|
||||||
|
/// colliding with whatever the machine running this happens to have open.
|
||||||
|
/// </remarks>
|
||||||
|
private static int UnusedPort()
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
var port = PortOf(listener);
|
||||||
|
listener.Stop();
|
||||||
|
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ internal sealed class FakeShellSession : ISshShellSession
|
|||||||
private readonly Lock gate = new();
|
private readonly Lock gate = new();
|
||||||
|
|
||||||
private readonly bool blockReads;
|
private readonly bool blockReads;
|
||||||
|
private readonly TimeSpan? pacePerRead;
|
||||||
|
|
||||||
private long remaining;
|
private long remaining;
|
||||||
private byte pattern;
|
private byte pattern;
|
||||||
@@ -26,10 +27,26 @@ internal sealed class FakeShellSession : ISshShellSession
|
|||||||
/// read loop racing the test for control of the pump's credit window — see the reattach tests in
|
/// read loop racing the test for control of the pump's credit window — see the reattach tests in
|
||||||
/// <c>TerminalWorkspaceTests</c>.
|
/// <c>TerminalWorkspaceTests</c>.
|
||||||
/// </param>
|
/// </param>
|
||||||
internal FakeShellSession(long bytesToProduce = 0, bool blockReads = false)
|
/// <param name="pacePerRead">
|
||||||
|
/// How long each read takes, or null for one that returns as fast as the CPU can fill a buffer.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The default producer emits at memory bandwidth, which is faster than any network and faster than
|
||||||
|
/// the pump can frame and send — useful for the throughput harness, and useless for asking whether a
|
||||||
|
/// terminal that <em>can</em> keep up loses anything, because nothing can keep up with it. A pace makes
|
||||||
|
/// the fake a link instead of a memcpy: 8 KiB every millisecond is 8 MB/s, which is a fast remote
|
||||||
|
/// rather than an impossible one.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal FakeShellSession(
|
||||||
|
long bytesToProduce = 0,
|
||||||
|
bool blockReads = false,
|
||||||
|
TimeSpan? pacePerRead = null)
|
||||||
{
|
{
|
||||||
remaining = bytesToProduce;
|
remaining = bytesToProduce;
|
||||||
this.blockReads = blockReads;
|
this.blockReads = blockReads;
|
||||||
|
this.pacePerRead = pacePerRead;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -81,6 +98,11 @@ internal sealed class FakeShellSession : ISshShellSession
|
|||||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pacePerRead is { } pace)
|
||||||
|
{
|
||||||
|
await Task.Delay(pace, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -212,6 +234,40 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts output without keeping it, and acknowledges it as a renderer that is keeping up would.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="RecordingTransport"/>'s cheap sibling, and the throughput harness needs it to be the one
|
||||||
|
/// measuring rather than the one measured: recording copies every frame into a list that grows to the size
|
||||||
|
/// of the whole run, which at the rates that harness drives makes the <em>test double</em> the slowest thing
|
||||||
|
/// in the pipeline. A harness whose consumer cannot keep up is measuring itself.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class CountingTransport : ITerminalTransport
|
||||||
|
{
|
||||||
|
private long outputBytes;
|
||||||
|
|
||||||
|
/// <summary>Set to acknowledge every output frame immediately, as a keeping-up renderer would.</summary>
|
||||||
|
internal TerminalSessionPump? AutoAcknowledge { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Payload bytes of every output frame sent so far.</summary>
|
||||||
|
internal long OutputBytes => Interlocked.Read(ref outputBytes);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (TerminalFrame.TryRead(frame.Span, out var opcode, out _, out var payload)
|
||||||
|
&& opcode == (byte)TerminalServerOpcode.Output)
|
||||||
|
{
|
||||||
|
_ = Interlocked.Add(ref outputBytes, payload.Length);
|
||||||
|
|
||||||
|
AutoAcknowledge?.Acknowledge((uint)payload.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Records frames, and can acknowledge them to keep credit flowing.</summary>
|
/// <summary>Records frames, and can acknowledge them to keep credit flowing.</summary>
|
||||||
internal sealed class RecordingTransport : ITerminalTransport
|
internal sealed class RecordingTransport : ITerminalTransport
|
||||||
{
|
{
|
||||||
@@ -270,6 +326,23 @@ internal sealed class RecordingTransport : ITerminalTransport
|
|||||||
return [.. output];
|
return [.. output];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Concatenated payloads of every frame of one opcode.</summary>
|
||||||
|
internal byte[] PayloadsOf(TerminalServerOpcode opcode)
|
||||||
|
{
|
||||||
|
var payloads = new List<byte>();
|
||||||
|
|
||||||
|
foreach (var frame in Frames)
|
||||||
|
{
|
||||||
|
if (TerminalFrame.TryRead(frame, out var actual, out _, out var payload)
|
||||||
|
&& actual == (byte)opcode)
|
||||||
|
{
|
||||||
|
payloads.AddRange(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. payloads];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Counts frames of one opcode.</summary>
|
/// <summary>Counts frames of one opcode.</summary>
|
||||||
internal int CountOf(TerminalServerOpcode opcode) =>
|
internal int CountOf(TerminalServerOpcode opcode) =>
|
||||||
Frames.Count(frame =>
|
Frames.Count(frame =>
|
||||||
|
|||||||
@@ -5,17 +5,36 @@
|
|||||||
|
|
||||||
It fakes exactly what the page's transport touches and nothing else: a WebSocket whose every step is
|
It fakes exactly what the page's transport touches and nothing else: a WebSocket whose every step is
|
||||||
driven from the test, a clock that only moves when a test moves it, and the two DOM objects the page
|
driven from the test, a clock that only moves when a test moves it, and the two DOM objects the page
|
||||||
reads at load. There is no Terminal, no fit addon and no WebGL here, because nothing on the reconnection
|
reads at load.
|
||||||
path builds a pane — a test that opens a session will have to add them, and should, rather than this
|
|
||||||
file guessing now at what such a test would want.
|
◆ AND, SINCE RendererClipboardTests, A PANE.
|
||||||
|
|
||||||
|
Which the reconnection tests never needed and this file used to say so, inviting whoever first opened a
|
||||||
|
session to add the Terminal, the fit addon and the WebGL addon rather than guessing at them in advance.
|
||||||
|
They are below, and they are stubs with a memory: a fake terminal records what was pasted into it, what
|
||||||
|
was typed into it and what it was asked to focus, because those three are the whole of what the page does
|
||||||
|
to a pane and therefore the whole of what a test can ask about. The selection is settable from a test,
|
||||||
|
since a selection is made with a mouse in a canvas and there is no canvas here.
|
||||||
|
|
||||||
|
What a fake terminal is NOT is xterm. It cannot say whether paste() brackets its argument or whether
|
||||||
|
getSelection() trims a row's right padding — that is xterm's behaviour, exercised by xterm's own suite and
|
||||||
|
by docs/manual-checks.md 1.11 here. What these tests can prove is which of the two the page calls, with
|
||||||
|
what, and addressed to which session, and that is where every mistake in this area has actually been.
|
||||||
|
|
||||||
◆ NOTHING HERE MAY DECLARE A NAME terminal.js ALSO DECLARES.
|
◆ NOTHING HERE MAY DECLARE A NAME terminal.js ALSO DECLARES.
|
||||||
|
|
||||||
Both files are evaluated as scripts into the same global scope, and the page's own `const root` against
|
Both files are evaluated as scripts into the same global scope, and a collision goes one of two ways.
|
||||||
a `var root` here is a SyntaxError before a line of either runs. That is why the page's two elements are
|
Against one of the page's `const`s — its own `root`, say — it is a SyntaxError before a line of either
|
||||||
|
runs, which is the good outcome: it is impossible to miss. That is why the page's two elements are
|
||||||
`pageRoot` and `pageBanner` below and reached through getElementById, which is how the page reaches them
|
`pageRoot` and `pageBanner` below and reached through getElementById, which is how the page reaches them
|
||||||
anyway.
|
anyway.
|
||||||
|
|
||||||
|
Against one of the page's *functions* there is no error at all. The page is evaluated second, so its
|
||||||
|
declaration silently replaces this file's and every test calling that name gets the page's function
|
||||||
|
instead — which is how `frame()` here, meant to read a sent frame back, quietly returned the ArrayBuffer
|
||||||
|
that terminal.js's own frame() builds instead, and failed six tests on a type error a long way from the
|
||||||
|
cause. Hence `sentFrames`/`sentFrame` below: prefixed on purpose, not for tidiness.
|
||||||
|
|
||||||
◆ AND THE FAKE SOCKET'S BEHAVIOUR IS THE PART TO GET RIGHT.
|
◆ AND THE FAKE SOCKET'S BEHAVIOUR IS THE PART TO GET RIGHT.
|
||||||
|
|
||||||
close() is the one method with a rule that is not obvious and that the tests lean on: a socket closed
|
close() is the one method with a rule that is not obvious and that the tests lean on: a socket closed
|
||||||
@@ -30,6 +49,15 @@ var clock = { now: 0, next: 1, timers: {} };
|
|||||||
/** Every socket the page has opened, in order, live or dead. */
|
/** Every socket the page has opened, in order, live or dead. */
|
||||||
var sockets = [];
|
var sockets = [];
|
||||||
|
|
||||||
|
/** Every frame the page has sent, in order, as arrays of byte values. */
|
||||||
|
var outbound = [];
|
||||||
|
|
||||||
|
/** Every fake terminal the page has opened on a pane, in the order it opened them. */
|
||||||
|
var terminals = [];
|
||||||
|
|
||||||
|
/** What the last right-click did about the browser's own menu. See rightClick(). */
|
||||||
|
var gesture = { prevented: 0, stopped: 0 };
|
||||||
|
|
||||||
function listeners(target) {
|
function listeners(target) {
|
||||||
target.handlers = {};
|
target.handlers = {};
|
||||||
|
|
||||||
@@ -41,11 +69,11 @@ function listeners(target) {
|
|||||||
target.handlers[name].push(handler);
|
target.handlers[name].push(handler);
|
||||||
};
|
};
|
||||||
|
|
||||||
target.fire = function (name) {
|
target.fire = function (name, event) {
|
||||||
var handlers = target.handlers[name] || [];
|
var handlers = target.handlers[name] || [];
|
||||||
|
|
||||||
for (var i = 0; i < handlers.length; i++) {
|
for (var i = 0; i < handlers.length; i++) {
|
||||||
handlers[i]({});
|
handlers[i](event || {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,7 +95,22 @@ FakeSocket.OPEN = 1;
|
|||||||
FakeSocket.CLOSING = 2;
|
FakeSocket.CLOSING = 2;
|
||||||
FakeSocket.CLOSED = 3;
|
FakeSocket.CLOSED = 3;
|
||||||
|
|
||||||
FakeSocket.prototype.send = function () {};
|
/**
|
||||||
|
* Records rather than discards, because half of what the page does on a right-click is a frame.
|
||||||
|
*
|
||||||
|
* Kept as raw bytes and decoded on the way out — see sentFrame() — so that a payload the page wrote as
|
||||||
|
* UTF-8 is read back as UTF-8 rather than as whatever a stringification would have made of it.
|
||||||
|
*/
|
||||||
|
FakeSocket.prototype.send = function (buffer) {
|
||||||
|
var bytes = new Uint8Array(buffer);
|
||||||
|
var copy = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
copy.push(bytes[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
outbound.push(copy);
|
||||||
|
};
|
||||||
|
|
||||||
/** What the page calls. See the note above on why a connecting socket goes quietly. */
|
/** What the page calls. See the note above on why a connecting socket goes quietly. */
|
||||||
FakeSocket.prototype.close = function () {
|
FakeSocket.prototype.close = function () {
|
||||||
@@ -93,9 +136,31 @@ var pageRoot = listeners({
|
|||||||
var document = listeners({
|
var document = listeners({
|
||||||
hidden: false,
|
hidden: false,
|
||||||
getElementById: function (id) { return id === 'root' ? pageRoot : pageBanner; },
|
getElementById: function (id) { return id === 'root' ? pageRoot : pageBanner; },
|
||||||
createElement: function () { return { dataset: {}, style: {}, remove: function () {} }; },
|
createElement: function () { return newElement(); },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pane, as far as the page can tell.
|
||||||
|
*
|
||||||
|
* With real dimensions, and not because anything would fail without them — which is the point. The page's
|
||||||
|
* guard is `clientWidth < MINIMUM_FITTABLE_PIXELS`, and an undefined width compares false against every
|
||||||
|
* number, so a fake with no dimensions slips past a rule it ought to have been caught by and gets fitted
|
||||||
|
* anyway. A fake that passes for the wrong reason stops matching the page the day that guard is written
|
||||||
|
* differently.
|
||||||
|
*
|
||||||
|
* Listeners, because the right-click handler is registered on this and there is nowhere else to fire it
|
||||||
|
* from.
|
||||||
|
*/
|
||||||
|
function newElement() {
|
||||||
|
return listeners({
|
||||||
|
dataset: {},
|
||||||
|
style: {},
|
||||||
|
clientWidth: 800,
|
||||||
|
clientHeight: 600,
|
||||||
|
remove: function () {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var window = listeners({});
|
var window = listeners({});
|
||||||
|
|
||||||
// The page warns through this on paths no reconnection test reaches. Present so that a test which does
|
// The page warns through this on paths no reconnection test reaches. Present so that a test which does
|
||||||
@@ -118,6 +183,142 @@ function ResizeObserver() {
|
|||||||
this.observe = function () {};
|
this.observe = function () {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── The pane's three globals, which the page reaches by the names the UMD builds attach ──────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A terminal that remembers what was done to it.
|
||||||
|
*
|
||||||
|
* `selection` is a plain field rather than anything computed, and settable from a test through select():
|
||||||
|
* a selection is made by dragging a mouse across a canvas, which is the one part of this there is no
|
||||||
|
* standing in for. What the tests are about is what the page does with whatever is selected.
|
||||||
|
*/
|
||||||
|
function FakeTerminal(options) {
|
||||||
|
this.options = options || {};
|
||||||
|
this.cols = 80;
|
||||||
|
this.rows = 24;
|
||||||
|
this.selection = '';
|
||||||
|
this.pastes = [];
|
||||||
|
this.inputs = [];
|
||||||
|
this.writes = [];
|
||||||
|
this.focuses = 0;
|
||||||
|
this.disposed = false;
|
||||||
|
this.pane = null;
|
||||||
|
|
||||||
|
terminals.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeTerminal.prototype.loadAddon = function () {};
|
||||||
|
FakeTerminal.prototype.open = function (pane) { this.pane = pane; };
|
||||||
|
FakeTerminal.prototype.attachCustomKeyEventHandler = function (handler) { this.keys = handler; };
|
||||||
|
FakeTerminal.prototype.onData = function (handler) { this.data = handler; };
|
||||||
|
FakeTerminal.prototype.onResize = function (handler) { this.resized = handler; };
|
||||||
|
FakeTerminal.prototype.focus = function () { this.focuses++; };
|
||||||
|
FakeTerminal.prototype.paste = function (text) { this.pastes.push(text); };
|
||||||
|
FakeTerminal.prototype.input = function (text) { this.inputs.push(text); };
|
||||||
|
FakeTerminal.prototype.getSelection = function () { return this.selection; };
|
||||||
|
FakeTerminal.prototype.clearSelection = function () { this.selection = ''; };
|
||||||
|
FakeTerminal.prototype.dispose = function () { this.disposed = true; };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The write callback is the acknowledgement, so it is called rather than kept.
|
||||||
|
*
|
||||||
|
* Synchronously, which a real xterm does not promise — it renders first. The page's contract is that the
|
||||||
|
* acknowledgement is sent from this callback and not before, and calling it straight away is what lets a
|
||||||
|
* test assert that without needing a frame clock.
|
||||||
|
*/
|
||||||
|
FakeTerminal.prototype.write = function (data, done) {
|
||||||
|
this.writes.push(data);
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var Terminal = FakeTerminal;
|
||||||
|
|
||||||
|
var FitAddon = {
|
||||||
|
FitAddon: function () {
|
||||||
|
this.fit = function () {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var WebglAddon = {
|
||||||
|
WebglAddon: function () {
|
||||||
|
this.onContextLoss = function () {};
|
||||||
|
this.dispose = function () {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── UTF-8, because the page encodes and decodes every payload through these ──────────────────────────
|
||||||
|
//
|
||||||
|
// Written out rather than stubbed as one byte per character. The page sends a selection as UTF-8 and reads
|
||||||
|
// a clipboard as UTF-8, so a harness that treated the two as Latin-1 would pass a test with an em dash in
|
||||||
|
// the selection and ship a page that mangled it.
|
||||||
|
|
||||||
|
function TextEncoder() {
|
||||||
|
this.encode = function (text) {
|
||||||
|
var bytes = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
var point = text.charCodeAt(i);
|
||||||
|
|
||||||
|
// A surrogate pair is one code point in two units. Combined here, because encoding each half on its
|
||||||
|
// own is exactly the mojibake this exists to catch.
|
||||||
|
if (point >= 0xd800 && point <= 0xdbff && i + 1 < text.length) {
|
||||||
|
var low = text.charCodeAt(i + 1);
|
||||||
|
|
||||||
|
if (low >= 0xdc00 && low <= 0xdfff) {
|
||||||
|
point = 0x10000 + ((point - 0xd800) << 10) + (low - 0xdc00);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (point < 0x80) {
|
||||||
|
bytes.push(point);
|
||||||
|
} else if (point < 0x800) {
|
||||||
|
bytes.push(0xc0 | (point >> 6), 0x80 | (point & 0x3f));
|
||||||
|
} else if (point < 0x10000) {
|
||||||
|
bytes.push(0xe0 | (point >> 12), 0x80 | ((point >> 6) & 0x3f), 0x80 | (point & 0x3f));
|
||||||
|
} else {
|
||||||
|
bytes.push(
|
||||||
|
0xf0 | (point >> 18),
|
||||||
|
0x80 | ((point >> 12) & 0x3f),
|
||||||
|
0x80 | ((point >> 6) & 0x3f),
|
||||||
|
0x80 | (point & 0x3f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(bytes);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextDecoder() {
|
||||||
|
this.decode = function (bytes) {
|
||||||
|
var text = '';
|
||||||
|
|
||||||
|
for (var i = 0; i < bytes.length;) {
|
||||||
|
var first = bytes[i];
|
||||||
|
var length = first < 0x80 ? 1 : first < 0xe0 ? 2 : first < 0xf0 ? 3 : 4;
|
||||||
|
var point = length === 1 ? first : first & (0x7f >> length);
|
||||||
|
|
||||||
|
for (var n = 1; n < length; n++) {
|
||||||
|
point = (point << 6) | (bytes[i + n] & 0x3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
i += length;
|
||||||
|
|
||||||
|
if (point > 0xffff) {
|
||||||
|
point -= 0x10000;
|
||||||
|
text += String.fromCharCode(0xd800 + (point >> 10), 0xdc00 + (point & 0x3ff));
|
||||||
|
} else {
|
||||||
|
text += String.fromCharCode(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── What the tests drive the page with ──────────────────────────────────────────────────────────────
|
// ── What the tests drive the page with ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,6 +366,134 @@ function banner() {
|
|||||||
return pageBanner.textContent;
|
return pageBanner.textContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many frames the page has sent since it loaded. */
|
||||||
|
function sentFrames() {
|
||||||
|
return outbound.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One frame the page sent, as "opcode/session/payload", the payload decoded as UTF-8.
|
||||||
|
*
|
||||||
|
* One string rather than three values because it crosses into C# as one, and a test asserting on
|
||||||
|
* "5/7/whoami" reads as the frame it is. Resize frames land here too and read as rubbish; nothing asserts
|
||||||
|
* on those, and rendering them some other way would mean a second shape for a reader to learn.
|
||||||
|
*/
|
||||||
|
function sentFrame(index) {
|
||||||
|
var bytes = outbound[index];
|
||||||
|
var session = ((bytes[1] << 24) | (bytes[2] << 16) | (bytes[3] << 8) | bytes[4]) >>> 0;
|
||||||
|
|
||||||
|
return bytes[0] + '/' + session + '/' + new TextDecoder().decode(bytes.slice(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands the page a frame, on whichever socket is its current one. */
|
||||||
|
function deliverFrame(opcode, sessionId, payload) {
|
||||||
|
var bytes = new Uint8Array(5 + payload.length);
|
||||||
|
var header = new DataView(bytes.buffer);
|
||||||
|
|
||||||
|
header.setUint8(0, opcode);
|
||||||
|
header.setUint32(1, sessionId);
|
||||||
|
bytes.set(payload, 5);
|
||||||
|
|
||||||
|
sockets[sockets.length - 1].fire('message', { data: bytes.buffer });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SessionOpened, which is what makes the page build a pane. */
|
||||||
|
function openSession(sessionId) {
|
||||||
|
deliverFrame(2, sessionId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clipboard: the host's answer to a right-click paste. */
|
||||||
|
function deliverClipboard(sessionId, text) {
|
||||||
|
deliverFrame(8, sessionId, new TextEncoder().encode(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paste: a snippet off the sidebar, which is deliberately not the same frame. */
|
||||||
|
function deliverSnippet(sessionId, text, execute) {
|
||||||
|
var body = new TextEncoder().encode(text);
|
||||||
|
var payload = new Uint8Array(1 + body.length);
|
||||||
|
|
||||||
|
payload[0] = execute ? 1 : 0;
|
||||||
|
payload.set(body, 1);
|
||||||
|
|
||||||
|
deliverFrame(6, sessionId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pane behind one session, found the way the page labelled it. */
|
||||||
|
function terminalFor(sessionId) {
|
||||||
|
for (var i = 0; i < terminals.length; i++) {
|
||||||
|
if (terminals[i].pane && terminals[i].pane.dataset.sessionId === String(sessionId)) {
|
||||||
|
return terminals[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No pane for session ' + sessionId + '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drags a selection across that pane, which is the one thing a fake terminal cannot do for itself. */
|
||||||
|
function select(sessionId, text) {
|
||||||
|
terminalFor(sessionId).selection = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is selected in that pane now — how a test sees that a copy dropped it. */
|
||||||
|
function selection(sessionId) {
|
||||||
|
return terminalFor(sessionId).selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right button, over that pane.
|
||||||
|
*
|
||||||
|
* The event carries only the two methods the page calls on it, and counts the calls: suppressing the
|
||||||
|
* browser's own menu is half of what this gesture has to do, and a handler that copied correctly and left
|
||||||
|
* the menu up would be a regression no assertion about frames could see.
|
||||||
|
*/
|
||||||
|
function rightClick(sessionId) {
|
||||||
|
gesture = { prevented: 0, stopped: 0 };
|
||||||
|
|
||||||
|
terminalFor(sessionId).pane.fire('contextmenu', {
|
||||||
|
preventDefault: function () { gesture.prevented++; },
|
||||||
|
stopPropagation: function () { gesture.stopped++; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right button somewhere that is not a pane — the status banner, in practice.
|
||||||
|
*
|
||||||
|
* Fired on the document rather than on an element, which is where the page's fallback rule lives: a pane
|
||||||
|
* stops the event before it gets here, so this is the path only a click that missed every pane takes.
|
||||||
|
*/
|
||||||
|
function rightClickOffAPane() {
|
||||||
|
gesture = { prevented: 0, stopped: 0 };
|
||||||
|
|
||||||
|
document.fire('contextmenu', {
|
||||||
|
preventDefault: function () { gesture.prevented++; },
|
||||||
|
stopPropagation: function () { gesture.stopped++; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the last right-click took the event away from the browser, and from xterm. */
|
||||||
|
function prevented() {
|
||||||
|
return gesture.prevented;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopped() {
|
||||||
|
return gesture.stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything paste() was given for that pane, joined — the bracketed route. */
|
||||||
|
function pasted(sessionId) {
|
||||||
|
return terminalFor(sessionId).pastes.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything input() was given for that pane, joined — the typed route. */
|
||||||
|
function typed(sessionId) {
|
||||||
|
return terminalFor(sessionId).inputs.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many times that pane has been asked for the caret. */
|
||||||
|
function focuses(sessionId) {
|
||||||
|
return terminalFor(sessionId).focuses;
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether that attempt has been closed, by the page or by the far end. */
|
/** Whether that attempt has been closed, by the page or by the far end. */
|
||||||
function isClosed(index) {
|
function isClosed(index) {
|
||||||
return sockets[index].readyState === FakeSocket.CLOSED;
|
return sockets[index].readyState === FakeSocket.CLOSED;
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
namespace DodoSSH.Client.Terminal.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The renderer page's right button: a copy, a paste, and never a menu.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The gesture every terminal has had since the Windows console got QuickEdit — select and right-click to
|
||||||
|
/// copy, right-click with nothing selected to paste. It lives in the page because that is the only place it
|
||||||
|
/// can: the selection is drawn into a canvas the host never sees, and once a terminal has focus the host's
|
||||||
|
/// window receives no input events at all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// What is asserted here is which frame the page sends, addressed to which session, and which of xterm's two
|
||||||
|
/// insertion routes it takes on the way back. What is deliberately <em>not</em> asserted is anything about
|
||||||
|
/// xterm itself — whether <c>paste()</c> really brackets, whether a selection really loses its right
|
||||||
|
/// padding — because the fake terminal in the harness is a stub with a memory and not an emulator. That
|
||||||
|
/// boundary is where <c>docs/manual-checks.md</c> 1.11 picks up.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class RendererClipboardTests
|
||||||
|
{
|
||||||
|
/// <summary>The session every single-pane test here opens.</summary>
|
||||||
|
private const uint SessionId = 7;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ARightClickOverASelection_CopiesIt()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("select(7, 'error: nothing was written')");
|
||||||
|
page.Do("rightClick(7)");
|
||||||
|
|
||||||
|
page.Sent.ShouldContain("5/7/error: nothing was written");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The console's behaviour, and load-bearing rather than cosmetic. Dropping the selection is the only
|
||||||
|
/// acknowledgement of the copy there is — nothing else on screen moves — and it is what makes the next
|
||||||
|
/// right-click a paste rather than a second copy of something forgotten about.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ARightClickOverASelection_DropsTheSelectionAfterCopyingIt()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("select(7, 'systemctl status sshd')");
|
||||||
|
page.Do("rightClick(7)");
|
||||||
|
|
||||||
|
page.Selection(SessionId).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ARightClickWithNothingSelected_AsksTheHostForTheClipboard()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("rightClick(7)");
|
||||||
|
|
||||||
|
// No payload: the page has nothing to send, which is the whole reason it has to ask.
|
||||||
|
page.Sent.ShouldContain("6/7/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The composite property, and the one a user would describe: copy, then paste. It passes only if the
|
||||||
|
/// copy dropped the selection — a page that left it standing sends a second copy frame here and never a
|
||||||
|
/// paste request, which is the failure that reads as "paste is broken".
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ASecondRightClick_PastesBecauseTheFirstOneCopied()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("select(7, 'df -h')");
|
||||||
|
page.Do("rightClick(7)");
|
||||||
|
page.Do("rightClick(7)");
|
||||||
|
|
||||||
|
page.Sent.ShouldContain("5/7/df -h");
|
||||||
|
page.Sent.ShouldContain("6/7/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <c>preventDefault</c> is the WebView's own context menu, which is what this whole change is about.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>stopPropagation</c> is xterm's, and it is the half that is easy to leave out. xterm registers its
|
||||||
|
/// own <c>contextmenu</c> handler on the element inside the pane, which parks a 20-pixel textarea under
|
||||||
|
/// the pointer at z-index 1000 and leaves the selection sitting in it, selected — both of which exist to
|
||||||
|
/// serve the native menu and are litter without one. The page takes the event in the capture phase to
|
||||||
|
/// get ahead of it; a listener that forgot the third argument to <c>addEventListener</c> would still
|
||||||
|
/// suppress the menu and would fail here.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ARightClick_TakesTheEventFromTheBrowserAndFromXterm()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("rightClick(7)");
|
||||||
|
|
||||||
|
page.Prevented.ShouldBe(1);
|
||||||
|
page.Stopped.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The panes cover the page — they are positioned at inset 0 — so the only right-click that misses one is
|
||||||
|
/// on the status banner, which is drawn over the bottom of the pane while it has something to say. That
|
||||||
|
/// used to be the one place in a terminal window that still opened a browser menu.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ARightClickThatMissesEveryPane_StillOpensNoMenu()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("rightClickOffAPane()");
|
||||||
|
|
||||||
|
page.Prevented.ShouldBe(1);
|
||||||
|
|
||||||
|
// And nothing is stopped, because there is nothing below the document to stop it from — nor any
|
||||||
|
// frame sent, because a click that is on no pane is a click about no session.
|
||||||
|
page.Stopped.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A right-click over a pane with a selection sends one frame and not two: the branch is exclusive, and a
|
||||||
|
/// page that asked for the clipboard as well would paste over the selection it had just copied.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ARightClickOverASelection_DoesNotAlsoAskForTheClipboard()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("select(7, 'uname -a')");
|
||||||
|
page.Do("rightClick(7)");
|
||||||
|
|
||||||
|
page.Sent.ShouldNotContain("6/7/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Two panes, because the session id on a clipboard frame is the whole of what puts the text where the
|
||||||
|
/// pointer was. They agree with the host's own idea of the active tab in every arrangement that exists
|
||||||
|
/// today; the page is what knows, so this is what proves the page says.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ARightClick_IsAddressedToThePaneItHappenedIn()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("openSession(9)");
|
||||||
|
page.Do("select(7, 'the first pane'); select(9, 'the second pane')");
|
||||||
|
|
||||||
|
page.Do("rightClick(9)");
|
||||||
|
|
||||||
|
page.Sent.ShouldContain("5/9/the second pane");
|
||||||
|
page.Sent.ShouldNotContain("5/7/the first pane");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other half of the round trip. <c>paste()</c> rather than <c>input()</c>, because this text came
|
||||||
|
/// off the system clipboard and that is the case bracketed paste exists for: a shell that receives a
|
||||||
|
/// multi-line command inside the markers treats every newline as text rather than as "run this".
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void TheClipboardComingBack_IsPastedRatherThanTyped()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("deliverClipboard(7, 'sudo systemctl restart sshd')");
|
||||||
|
|
||||||
|
page.Pasted(SessionId).ShouldBe("sudo systemctl restart sshd");
|
||||||
|
page.Typed(SessionId).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// No Enter, ever. A clipboard paste inserts and stops; pressing Enter for somebody is what the sidebar's
|
||||||
|
/// RUN does, and that is a thing they asked for by name.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void TheClipboardComingBack_DoesNotPressEnter()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("deliverClipboard(7, 'rm -rf /var/tmp/build')");
|
||||||
|
|
||||||
|
page.Typed(SessionId).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The caret follows the paste, so the next keystroke lands where the text did.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void TheClipboardComingBack_LeavesTheCaretInThatPane()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
var before = page.Focuses(SessionId);
|
||||||
|
page.Do("deliverClipboard(7, 'whoami')");
|
||||||
|
|
||||||
|
page.Focuses(SessionId).ShouldBe(before + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Multi-byte, because both directions run through the page's own <c>TextEncoder</c> and
|
||||||
|
/// <c>TextDecoder</c> and a character that straddles nothing is not a test of either. The harness's codec
|
||||||
|
/// is a real UTF-8 one for this reason; see its remark.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void TheTextSurvivesTheRoundTrip_WhenItIsNotAscii()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("select(7, 'échec — 90° · λ')");
|
||||||
|
page.Do("rightClick(7)");
|
||||||
|
page.Do("deliverClipboard(7, 'échec — 90° · λ')");
|
||||||
|
|
||||||
|
page.Sent.ShouldContain("5/7/échec — 90° · λ");
|
||||||
|
page.Pasted(SessionId).ShouldBe("échec — 90° · λ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The distinction the new opcode exists to keep, asserted from the other side: a snippet off the sidebar
|
||||||
|
/// is still <em>typed</em> when it is a single line, because bash marks a bracketed paste as an active
|
||||||
|
/// region and a snippet should read as though it had been typed. Sharing one opcode with the clipboard
|
||||||
|
/// would have meant choosing one of these two behaviours for both.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ASingleLineSnippet_IsStillTypedRatherThanPasted()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("deliverSnippet(7, 'tail -f /var/log/syslog', false)");
|
||||||
|
|
||||||
|
page.Typed(SessionId).ShouldBe("tail -f /var/log/syslog");
|
||||||
|
page.Pasted(SessionId).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ASingleLineSnippet_IsStillTypedRatherThanPasted" />
|
||||||
|
[Fact]
|
||||||
|
public void AMultiLineSnippet_IsStillPastedRatherThanTyped()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("deliverSnippet(7, 'cd /srv\\nmake\\nmake install', false)");
|
||||||
|
|
||||||
|
page.Pasted(SessionId).ShouldBe("cd /srv\nmake\nmake install");
|
||||||
|
page.Typed(SessionId).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// And the Enter goes through the typed route even for a multi-line snippet, deliberately outside the
|
||||||
|
/// bracketing: a <c>\r</c> inside the wrapper arrives at the shell as a literal carriage return and
|
||||||
|
/// nothing runs.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ASnippetWithRun_PressesEnterOutsideTheBrackets()
|
||||||
|
{
|
||||||
|
var page = Attached();
|
||||||
|
|
||||||
|
page.Do("deliverSnippet(7, 'cd /srv\\nmake', true)");
|
||||||
|
|
||||||
|
page.Pasted(SessionId).ShouldBe("cd /srv\nmake");
|
||||||
|
page.Typed(SessionId).ShouldBe("\r");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A page with an open socket and one pane, which is where every test here starts.</summary>
|
||||||
|
private static RendererPage Attached()
|
||||||
|
{
|
||||||
|
var page = RendererPage.Load();
|
||||||
|
|
||||||
|
// The socket has to be open before anything is sent: send() drops a frame when it is not, which
|
||||||
|
// would make every assertion here pass or fail on the transport rather than on the gesture.
|
||||||
|
page.Do("accept(0)");
|
||||||
|
page.Do("openSession(7)");
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,60 @@ internal sealed class RendererPage
|
|||||||
/// <summary>What the status element says — the banner a user would be looking at.</summary>
|
/// <summary>What the status element says — the banner a user would be looking at.</summary>
|
||||||
public string Banner => engine.Evaluate("banner()").AsString();
|
public string Banner => engine.Evaluate("banner()").AsString();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every frame the page has sent, each as <c>opcode/session/payload</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Materialised on each read rather than cached, because the point of it is what the page did in answer
|
||||||
|
/// to the last <see cref="Do"/>. A session's own resize frames are in here too and read as rubbish; see
|
||||||
|
/// the harness's <c>sentFrame()</c> for why they are not rendered some other way.
|
||||||
|
/// </remarks>
|
||||||
|
public IReadOnlyList<string> Sent
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var count = (int)engine.Evaluate("sentFrames()").AsNumber();
|
||||||
|
var frames = new List<string>(count);
|
||||||
|
|
||||||
|
for (var index = 0; index < count; index++)
|
||||||
|
{
|
||||||
|
frames.Add(
|
||||||
|
engine
|
||||||
|
.Evaluate(string.Create(CultureInfo.InvariantCulture, $"sentFrame({index})"))
|
||||||
|
.AsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How many times the last right-click called <c>preventDefault</c>, and <c>stopPropagation</c>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both, because they answer different questions: the first is whether the WebView's own context menu
|
||||||
|
/// was suppressed, and the second is whether xterm's own right-click handling was — which is what keeps
|
||||||
|
/// a 20-pixel textarea from being parked under the pointer with the selection sitting in it.
|
||||||
|
/// </remarks>
|
||||||
|
public int Prevented => (int)engine.Evaluate("prevented()").AsNumber();
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Prevented" />
|
||||||
|
public int Stopped => (int)engine.Evaluate("stopped()").AsNumber();
|
||||||
|
|
||||||
|
/// <summary>What is selected in that session's pane.</summary>
|
||||||
|
public string Selection(uint sessionId) =>
|
||||||
|
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"selection({sessionId})")).AsString();
|
||||||
|
|
||||||
|
/// <summary>Everything <c>term.paste</c> was given for that session — the bracketed route.</summary>
|
||||||
|
public string Pasted(uint sessionId) =>
|
||||||
|
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"pasted({sessionId})")).AsString();
|
||||||
|
|
||||||
|
/// <summary>Everything <c>term.input</c> was given for that session — the typed route.</summary>
|
||||||
|
public string Typed(uint sessionId) =>
|
||||||
|
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"typed({sessionId})")).AsString();
|
||||||
|
|
||||||
|
/// <summary>How many times that session's pane has been asked for the caret.</summary>
|
||||||
|
public int Focuses(uint sessionId) =>
|
||||||
|
(int)engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"focuses({sessionId})")).AsNumber();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads the harness and then the page, leaving the page exactly as it is a moment after the WebView
|
/// Loads the harness and then the page, leaving the page exactly as it is a moment after the WebView
|
||||||
/// navigated to it: one socket opened and still connecting.
|
/// navigated to it: one socket opened and still connecting.
|
||||||
|
|||||||
@@ -356,6 +356,82 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
|||||||
steps[0].ShouldBe(-1);
|
steps[0].ShouldBe(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Dispatched before the session lookup for the reason the text-size chord is, and one of its own: a
|
||||||
|
/// clipboard is not a property of any terminal. The absence of a registered session is the point of the
|
||||||
|
/// test — copying the last thing a remote said before it hung up is an ordinary thing to want, and a
|
||||||
|
/// dead tab must not be the one place the gesture silently does nothing.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ACopy_IsHeardWithNoSessionRegistered()
|
||||||
|
{
|
||||||
|
Start();
|
||||||
|
|
||||||
|
using var socket = await ConnectAsync();
|
||||||
|
|
||||||
|
var copied = new List<string>();
|
||||||
|
plane.ClipboardCopyRequested += (_, e) => copied.Add(e.Text);
|
||||||
|
|
||||||
|
await SendAsync(
|
||||||
|
socket,
|
||||||
|
(byte)TerminalClientOpcode.ClipboardCopy,
|
||||||
|
Encoding.UTF8.GetBytes("error: nothing was written"));
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => copied.Count > 0);
|
||||||
|
|
||||||
|
copied[0].ShouldBe("error: nothing was written");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// An empty copy is dropped rather than forwarded. The clipboard is shared with everything else on the
|
||||||
|
/// machine, so "copy nothing" would be a gesture that quietly destroys whatever was on it — and the page
|
||||||
|
/// does not send one, which makes this a guard against a frame from something that is not the page.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEmptyCopy_IsDropped()
|
||||||
|
{
|
||||||
|
Start();
|
||||||
|
|
||||||
|
using var socket = await ConnectAsync();
|
||||||
|
|
||||||
|
var copies = 0;
|
||||||
|
plane.ClipboardCopyRequested += (_, _) => Interlocked.Increment(ref copies);
|
||||||
|
|
||||||
|
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardCopy, []);
|
||||||
|
|
||||||
|
// Followed by one that must be heard, so the assertion below is about ordering rather than about
|
||||||
|
// having waited long enough: the receive loop is sequential, so a copy arriving after this proves
|
||||||
|
// the empty one was seen and passed over.
|
||||||
|
var copied = new List<string>();
|
||||||
|
plane.ClipboardCopyRequested += (_, e) => copied.Add(e.Text);
|
||||||
|
|
||||||
|
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardCopy, "uptime"u8.ToArray());
|
||||||
|
await WaitUntilAsync(() => copied.Count > 0);
|
||||||
|
|
||||||
|
copies.ShouldBe(1, "only the second frame carried anything");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The session id is the whole payload of a paste request — it says which pane the pointer was over, and
|
||||||
|
/// therefore where the answer has to land. Heard with nothing registered for the reason a copy is.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task APasteRequest_CarriesTheSessionItWasMadeIn()
|
||||||
|
{
|
||||||
|
Start();
|
||||||
|
|
||||||
|
using var socket = await ConnectAsync();
|
||||||
|
|
||||||
|
var asked = new List<uint>();
|
||||||
|
plane.ClipboardPasteRequested += (_, e) => asked.Add(e.SessionId);
|
||||||
|
|
||||||
|
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardPaste, [], sessionId: 4242);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => asked.Count > 0);
|
||||||
|
|
||||||
|
asked[0].ShouldBe(4242u);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AFrameForAnUnregisteredSession_IsIgnored()
|
public async Task AFrameForAnUnregisteredSession_IsIgnored()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
using DodoSSH.Client.Ssh;
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Terminal.Tests;
|
namespace DodoSSH.Client.Terminal.Tests;
|
||||||
@@ -84,13 +85,22 @@ public sealed class TerminalSessionPumpTests
|
|||||||
await run;
|
await run;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A renderer that has stopped acknowledging does not stop the pump reading, and does not grow it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ THIS TEST REPLACES ONE THAT ASSERTED THE OPPOSITE, and the reversal is the point rather than a
|
||||||
|
/// relaxation. The old contract was that the pump reads exactly one credit window and then stops,
|
||||||
|
/// which was believed to close SSH's receive window and block the remote. It does not — see
|
||||||
|
/// <see cref="OutputBacklog"/> — so all the old behaviour bought was an unbounded buffer inside
|
||||||
|
/// SSH.NET instead of a bounded one here. What has to be true now is both halves at once: reading
|
||||||
|
/// continues, so nothing piles up underneath, <em>and</em> what is held stays inside the backlog.
|
||||||
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WhenCreditRunsOut_ThePumpStopsReading()
|
public async Task WhenCreditRunsOut_ThePumpKeepsReadingAndTheBacklogHoldsTheLine()
|
||||||
{
|
{
|
||||||
// The property the whole design rests on. With no acknowledgement the pump must read exactly
|
|
||||||
// the window and then stop, so nothing accumulates between the socket and the screen. Left
|
|
||||||
// unbounded, a remote running `yes` grows the client's memory until it dies.
|
|
||||||
const int Window = 4 * 1024;
|
const int Window = 4 * 1024;
|
||||||
|
const int Backlog = 64 * 1024;
|
||||||
|
|
||||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||||
var transport = new RecordingTransport();
|
var transport = new RecordingTransport();
|
||||||
@@ -104,19 +114,32 @@ public sealed class TerminalSessionPumpTests
|
|||||||
ReadBufferBytes = 1024,
|
ReadBufferBytes = 1024,
|
||||||
FlushInterval = TimeSpan.FromMilliseconds(5),
|
FlushInterval = TimeSpan.FromMilliseconds(5),
|
||||||
WindowBytes = Window,
|
WindowBytes = Window,
|
||||||
|
BacklogBytes = Backlog,
|
||||||
});
|
});
|
||||||
|
|
||||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
|
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
pump.BytesRead.ShouldBe(
|
var readByNow = pump.BytesRead;
|
||||||
Window,
|
|
||||||
"The pump read past its credit window, so output accumulates without bound.");
|
|
||||||
|
|
||||||
// Still stalled a moment later, rather than merely slow.
|
readByNow.ShouldBeGreaterThan(
|
||||||
|
Window,
|
||||||
|
"The pump stopped reading, which leaves the flood accumulating inside SSH.NET instead.");
|
||||||
|
|
||||||
|
pump.BytesPending.ShouldBeLessThanOrEqualTo(
|
||||||
|
Backlog,
|
||||||
|
"The backlog grew past its capacity, which is the unbounded growth it exists to prevent.");
|
||||||
|
|
||||||
|
pump.BytesDropped.ShouldBeGreaterThan(
|
||||||
|
0,
|
||||||
|
"Nothing was dropped, so something is still holding what the renderer never took.");
|
||||||
|
|
||||||
|
// Still reading a moment later, rather than having quietly stalled once the backlog filled.
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||||
pump.BytesRead.ShouldBe(Window);
|
|
||||||
|
pump.BytesRead.ShouldBeGreaterThan(readByNow);
|
||||||
|
pump.BytesPending.ShouldBeLessThanOrEqualTo(Backlog);
|
||||||
|
|
||||||
pump.Credits.Available.ShouldBe(0);
|
pump.Credits.Available.ShouldBe(0);
|
||||||
|
|
||||||
@@ -125,10 +148,13 @@ public sealed class TerminalSessionPumpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgingCredit_LetsThePumpResume()
|
public async Task AcknowledgingCredit_LetsMoreOutputThrough()
|
||||||
{
|
{
|
||||||
const int Window = 4 * 1024;
|
const int Window = 4 * 1024;
|
||||||
|
|
||||||
|
// Endless, so the backlog never completes while this is being measured: once it does, the tail
|
||||||
|
// goes out regardless of credit and there is nothing for the window to hold back. Drops along the
|
||||||
|
// way change which bytes are sent, never how many — and the notice they raise is its own opcode.
|
||||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||||
var transport = new RecordingTransport();
|
var transport = new RecordingTransport();
|
||||||
|
|
||||||
@@ -145,13 +171,16 @@ public sealed class TerminalSessionPumpTests
|
|||||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||||
pump.BytesRead.ShouldBe(Window);
|
|
||||||
|
// One window's worth has gone out and stopped there, because nothing has been rendered.
|
||||||
|
var sentBeforeAcknowledgement = transport.OutputBytes().Length;
|
||||||
|
sentBeforeAcknowledgement.ShouldBe(Window);
|
||||||
|
|
||||||
pump.Acknowledge(2048);
|
pump.Acknowledge(2048);
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
pump.BytesRead.ShouldBe(Window + 2048);
|
transport.OutputBytes().Length.ShouldBe(sentBeforeAcknowledgement + 2048);
|
||||||
|
|
||||||
await pump.DisposeAsync();
|
await pump.DisposeAsync();
|
||||||
await run;
|
await run;
|
||||||
@@ -161,9 +190,10 @@ public sealed class TerminalSessionPumpTests
|
|||||||
public async Task AnOverLargeAcknowledgement_DoesNotWidenTheWindow()
|
public async Task AnOverLargeAcknowledgement_DoesNotWidenTheWindow()
|
||||||
{
|
{
|
||||||
// The acknowledgement comes from JavaScript. A page claiming to have rendered a gigabyte must
|
// The acknowledgement comes from JavaScript. A page claiming to have rendered a gigabyte must
|
||||||
// not be able to talk the host into an unbounded read.
|
// not be able to talk the host into filling its own buffer without limit.
|
||||||
const int Window = 4 * 1024;
|
const int Window = 4 * 1024;
|
||||||
|
|
||||||
|
// Endless for the reason the test above gives: a backlog that completes stops the window mattering.
|
||||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||||
var transport = new RecordingTransport();
|
var transport = new RecordingTransport();
|
||||||
|
|
||||||
@@ -185,7 +215,8 @@ public sealed class TerminalSessionPumpTests
|
|||||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
// One further window's worth at most, never everything the endless producer could offer.
|
// One further window's worth at most, never everything the endless producer could offer.
|
||||||
pump.BytesRead.ShouldBeLessThanOrEqualTo(Window * 2);
|
transport.OutputBytes().Length.ShouldBeLessThanOrEqualTo(Window * 2);
|
||||||
|
pump.Credits.Outstanding.ShouldBeLessThanOrEqualTo(Window);
|
||||||
|
|
||||||
await pump.DisposeAsync();
|
await pump.DisposeAsync();
|
||||||
await run;
|
await run;
|
||||||
@@ -344,7 +375,11 @@ public sealed class TerminalSessionPumpTests
|
|||||||
const int Window = CreditWindow.DefaultWindowBytes;
|
const int Window = CreditWindow.DefaultWindowBytes;
|
||||||
|
|
||||||
await using var session = new FakeShellSession(bytesToProduce: Target);
|
await using var session = new FakeShellSession(bytesToProduce: Target);
|
||||||
var transport = new RecordingTransport();
|
|
||||||
|
// Counting rather than recording, for the reason CountingTransport carries: a consumer that
|
||||||
|
// copies every frame into a 32 MB list is the slowest thing in this pipeline, and a harness
|
||||||
|
// whose consumer cannot keep up measures the harness.
|
||||||
|
var transport = new CountingTransport();
|
||||||
|
|
||||||
await using var pump = CreatePump(
|
await using var pump = CreatePump(
|
||||||
session,
|
session,
|
||||||
@@ -369,7 +404,15 @@ public sealed class TerminalSessionPumpTests
|
|||||||
var peakOutstanding = await sampler;
|
var peakOutstanding = await sampler;
|
||||||
|
|
||||||
pump.BytesRead.ShouldBe(Target);
|
pump.BytesRead.ShouldBe(Target);
|
||||||
transport.OutputBytes().Length.ShouldBe((int)Target);
|
|
||||||
|
// ◆ Every byte read either reached the renderer or was counted as dropped, and none of it went
|
||||||
|
// anywhere else. That is the invariant worth asserting here rather than "nothing was dropped":
|
||||||
|
// this producer emits at memory bandwidth with no network under it, which is faster than any
|
||||||
|
// real remote and faster than the pump can frame and send, so drops are the honest outcome and
|
||||||
|
// the harness would be lying to forbid them. What must never happen is a byte that is neither
|
||||||
|
// delivered nor accounted for — see NothingIsDropped_WhenTheRendererKeepsUpWithTheRemote for
|
||||||
|
// the case where the producer is one a network could actually deliver.
|
||||||
|
(transport.OutputBytes + pump.BytesDropped).ShouldBe(Target);
|
||||||
|
|
||||||
var megabytesPerSecond = Target / 1024d / 1024d / stopwatch.Elapsed.TotalSeconds;
|
var megabytesPerSecond = Target / 1024d / 1024d / stopwatch.Elapsed.TotalSeconds;
|
||||||
|
|
||||||
@@ -387,6 +430,142 @@ public sealed class TerminalSessionPumpTests
|
|||||||
$"Peak in-flight was {peakOutstanding} bytes against a {Window}-byte window."));
|
$"Peak in-flight was {peakOutstanding} bytes against a {Window}-byte window."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A renderer that keeps up loses nothing, however much the remote sends.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The other half of the trade the backlog makes, and the half a user actually lives in: dropping is
|
||||||
|
/// for a terminal that cannot keep up, and a terminal that can must show every byte. The producer here
|
||||||
|
/// is sized for a real link rather than for memory bandwidth — several times the backlog, delivered in
|
||||||
|
/// pieces a network could carry — so a drop would mean the pump losing output it had room for.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task NothingIsDropped_WhenTheRendererKeepsUpWithTheRemote()
|
||||||
|
{
|
||||||
|
const int Backlog = 256 * 1024;
|
||||||
|
const long Target = 512L * 1024;
|
||||||
|
|
||||||
|
// 8 KiB a millisecond — 8 MB/s, a fast remote rather than an impossible one, and thirty-two
|
||||||
|
// times the backlog's capacity per second so the pump has no room to be merely lucky.
|
||||||
|
await using var session = new FakeShellSession(
|
||||||
|
bytesToProduce: Target,
|
||||||
|
pacePerRead: TimeSpan.FromMilliseconds(1));
|
||||||
|
|
||||||
|
var transport = new CountingTransport();
|
||||||
|
|
||||||
|
await using var pump = CreatePump(
|
||||||
|
session,
|
||||||
|
transport,
|
||||||
|
new TerminalPumpOptions
|
||||||
|
{
|
||||||
|
ReadBufferBytes = 8 * 1024,
|
||||||
|
FlushInterval = TimeSpan.FromMilliseconds(1),
|
||||||
|
WindowBytes = CreditWindow.DefaultWindowBytes,
|
||||||
|
BacklogBytes = Backlog,
|
||||||
|
});
|
||||||
|
|
||||||
|
transport.AutoAcknowledge = pump;
|
||||||
|
|
||||||
|
await pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
pump.BytesDropped.ShouldBe(0);
|
||||||
|
transport.OutputBytes.ShouldBe(Target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A shell that ends against a renderer which stopped acknowledging still finishes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ THE HANG THIS DESIGN MAKES POSSIBLE, AND THE TEST THAT SAYS IT DOES NOT HAPPEN. The flush loop
|
||||||
|
/// waits on credit when the renderer is behind, and completing the backlog is a different signal from
|
||||||
|
/// returning credit — so a run whose reader finished while the flusher was parked on an
|
||||||
|
/// acknowledgement that will never come would wait for ever. Nothing above would notice: the shell has
|
||||||
|
/// ended, but the tab is told by <c>SessionEnded</c>, which fires off the run task completing, and the
|
||||||
|
/// connection is given back at the same moment. A session in that state is invisible and permanent.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task WhenTheRemoteCloses_TheRunEndsEvenIfTheRendererNeverAcknowledged()
|
||||||
|
{
|
||||||
|
const int Window = 4 * 1024;
|
||||||
|
|
||||||
|
// More than the window, so the flusher has certainly run out of credit and parked by the time the
|
||||||
|
// producer reaches the end of what it has to give.
|
||||||
|
await using var session = new FakeShellSession(bytesToProduce: Window * 4);
|
||||||
|
|
||||||
|
// No AutoAcknowledge, ever: this renderer's process died holding the socket.
|
||||||
|
var transport = new RecordingTransport();
|
||||||
|
|
||||||
|
await using var pump = CreatePump(
|
||||||
|
session,
|
||||||
|
transport,
|
||||||
|
new TerminalPumpOptions
|
||||||
|
{
|
||||||
|
ReadBufferBytes = 1024,
|
||||||
|
FlushInterval = TimeSpan.FromMilliseconds(5),
|
||||||
|
WindowBytes = Window,
|
||||||
|
});
|
||||||
|
|
||||||
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await Should.NotThrowAsync(() => run.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
pump.Outcome.End.ShouldBe(SshSessionEnd.RemoteClosed);
|
||||||
|
|
||||||
|
// And the tail went out rather than being abandoned with the flusher: the last thing a remote
|
||||||
|
// writes is usually why it ended.
|
||||||
|
transport.OutputBytes().Length.ShouldBe(Window * 4);
|
||||||
|
transport.CountOf(TerminalServerOpcode.SessionClosed).ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A drop is announced in the pane rather than leaving a silent gap.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The thing that makes dropping defensible at all. A terminal quietly showing a different transcript
|
||||||
|
/// from the one the remote sent is worse than one that says it could not keep up, because the second
|
||||||
|
/// can be believed. Its own opcode, so the sentence is never counted as something the remote said —
|
||||||
|
/// see <see cref="TerminalServerOpcode.OutputDropped"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task WhenOutputIsDropped_TheUserIsToldInThePane()
|
||||||
|
{
|
||||||
|
const int Window = 4 * 1024;
|
||||||
|
const int Backlog = 16 * 1024;
|
||||||
|
|
||||||
|
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||||
|
var transport = new RecordingTransport();
|
||||||
|
|
||||||
|
await using var pump = CreatePump(
|
||||||
|
session,
|
||||||
|
transport,
|
||||||
|
new TerminalPumpOptions
|
||||||
|
{
|
||||||
|
ReadBufferBytes = 1024,
|
||||||
|
FlushInterval = TimeSpan.FromMilliseconds(5),
|
||||||
|
WindowBytes = Window,
|
||||||
|
BacklogBytes = Backlog,
|
||||||
|
});
|
||||||
|
|
||||||
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Enough credit for one more frame, which is what carries the notice out.
|
||||||
|
pump.Acknowledge((uint)Window);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
transport.CountOf(TerminalServerOpcode.OutputDropped).ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
var notice = Encoding.UTF8.GetString(transport.PayloadsOf(TerminalServerOpcode.OutputDropped));
|
||||||
|
|
||||||
|
notice.ShouldContain("of output was dropped");
|
||||||
|
notice.ShouldStartWith("\u001b[0m");
|
||||||
|
|
||||||
|
await pump.DisposeAsync();
|
||||||
|
await run;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Watches in-flight bytes for the duration of a run, returning the highest seen.</summary>
|
/// <summary>Watches in-flight bytes for the duration of a run, returning the highest seen.</summary>
|
||||||
private static async Task<int> SamplePeakOutstandingAsync(
|
private static async Task<int> SamplePeakOutstandingAsync(
|
||||||
TerminalSessionPump pump,
|
TerminalSessionPump pump,
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ public sealed class TerminalWorkspaceTests
|
|||||||
/// <summary>How long <see cref="WaitUntilAsync"/> polls before calling it a failure.</summary>
|
/// <summary>How long <see cref="WaitUntilAsync"/> polls before calling it a failure.</summary>
|
||||||
private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10);
|
private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long <see cref="ConnectRendererAsync"/> waits for the attach's replay before calling it a
|
||||||
|
/// failure. Generous, because it bounds a hang rather than timing anything.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan ReplayTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WaitingForARendererThatNeverAttaches_GivesUp()
|
public async Task WaitingForARendererThatNeverAttaches_GivesUp()
|
||||||
{
|
{
|
||||||
@@ -83,9 +89,9 @@ public sealed class TerminalWorkspaceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The case a naive <c>sessions.Count</c> gets wrong. Nothing removes the entry when the remote
|
/// The case a naive <c>sessions.Count</c> gets wrong. Nothing removes the entry when the remote closes
|
||||||
/// closes the channel by itself — the session is still in the dictionary, holding a connection that
|
/// the channel by itself — what it held is given back, but the entry stays so the tab can go on saying
|
||||||
/// is finished — so counting entries would report a shell that exited as still running. A user
|
/// the session ended — so counting entries would report a shell that exited as still running. A user
|
||||||
/// deciding whether it is safe to walk away is the person that lie is told to.
|
/// deciding whether it is safe to walk away is the person that lie is told to.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -268,6 +274,69 @@ public sealed class TerminalWorkspaceTests
|
|||||||
.ShouldBeFalse("the tab it names is gone");
|
.ShouldBeFalse("the tab it names is gone");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The right-click paste's answer, and it is a separate frame from the snippet one above rather than the
|
||||||
|
/// same frame with a flag: the page brackets this unconditionally and types a single-line snippet, which
|
||||||
|
/// is a difference of behaviour and not of degree. See <see cref="TerminalServerOpcode.Clipboard"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The payload is the clipboard's text and nothing else — no leading flag byte, because there is no
|
||||||
|
/// Enter to ask for. A frame that grew one would put a stray character at the front of every paste, and
|
||||||
|
/// this is what would catch it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheClipboard_ReachesThePaneThatAskedForIt()
|
||||||
|
{
|
||||||
|
var connections = new FakeConnectionFactory(blockShellReads: true);
|
||||||
|
|
||||||
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
workspace.Start();
|
||||||
|
|
||||||
|
using var renderer = await ConnectRendererAsync(workspace);
|
||||||
|
|
||||||
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// The session's opening frame, read and discarded so the clipboard frame below is the next one.
|
||||||
|
await ReceiveFrameAsync(renderer);
|
||||||
|
|
||||||
|
(await workspace.SendClipboardAsync(
|
||||||
|
sessionId, "sudo systemctl restart sshd", TestContext.Current.CancellationToken))
|
||||||
|
.ShouldBeTrue("the session is live");
|
||||||
|
|
||||||
|
var frame = await ReceiveFrameAsync(renderer);
|
||||||
|
|
||||||
|
frame.Opcode.ShouldBe((byte)TerminalServerOpcode.Clipboard);
|
||||||
|
frame.SessionId.ShouldBe(sessionId);
|
||||||
|
Encoding.UTF8.GetString(frame.Payload).ShouldBe("sudo systemctl restart sshd");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Refused rather than sent and dropped, for the reason <c>PasteAsync</c> is — with the difference that
|
||||||
|
/// nothing on screen would have shown it either way, so this answer exists to be honest rather than to
|
||||||
|
/// be reported. See <see cref="TerminalWorkspace.SendClipboardAsync"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SendingTheClipboardToADeadSession_SaysSoRatherThanDroppingIt()
|
||||||
|
{
|
||||||
|
var connections = new FakeConnectionFactory();
|
||||||
|
|
||||||
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
(await workspace.SendClipboardAsync(9999, "uptime", TestContext.Current.CancellationToken))
|
||||||
|
.ShouldBeFalse("this workspace never issued that id");
|
||||||
|
|
||||||
|
await workspace.CloseSessionAsync(sessionId);
|
||||||
|
|
||||||
|
(await workspace.SendClipboardAsync(sessionId, "uptime", TestContext.Current.CancellationToken))
|
||||||
|
.ShouldBeFalse("the tab it names is gone");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The event the tab strip and the phone's keep-alive listen to, so a dot can go out — and a foreground
|
/// The event the tab strip and the phone's keep-alive listen to, so a dot can go out — and a foreground
|
||||||
/// notification can come down — the moment a shell exits rather than at the next thing that happens to
|
/// notification can come down — the moment a shell exits rather than at the next thing that happens to
|
||||||
@@ -582,6 +651,86 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
|
await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A shell that ended on its own gives its connection back at once, without waiting for the tab.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ THE HALF THAT WAS MISSING. Closing a tab disposed the connection and always had; a remote hanging
|
||||||
|
/// up did not, so a session that ended went on holding an authenticated socket, a keep-alive putting a
|
||||||
|
/// packet on the wire every thirty seconds, and SSH.NET's read buffer at whatever size the session's
|
||||||
|
/// busiest moment had grown it to — for as long as the user left the tab open, which for a tab showing
|
||||||
|
/// the last thing a job said is indefinitely.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ASessionWhoseRemoteHasExited_GivesBackItsConnectionWithoutWaitingForTheTab()
|
||||||
|
{
|
||||||
|
// A shell with no output to give: its first read returns 0, which is the remote closing the
|
||||||
|
// channel, so the pump finishes with nobody having asked it to.
|
||||||
|
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||||
|
|
||||||
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => connections.Connections.TrueForAll(connection => connection.IsDisposed));
|
||||||
|
|
||||||
|
var connection = connections.Connections.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
connection.IsConnected.ShouldBeFalse();
|
||||||
|
connection.Shell.ShouldNotBeNull().IsOpen.ShouldBeFalse();
|
||||||
|
|
||||||
|
// The entry itself stays, and has to: the tab is still on screen, still says it ended, and still
|
||||||
|
// offers a reconnect — all of which read this workspace rather than the tab strip's own memory.
|
||||||
|
workspace.CreditsFor(sessionId).ShouldNotBeNull();
|
||||||
|
workspace.IsSessionLive(sessionId).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The consequence of the release above, and the reason <c>SendInputAsync</c> asks about liveness
|
||||||
|
/// rather than presence: the shell channel is disposed the moment the remote hangs up, so an accessory
|
||||||
|
/// key pressed at a tab that ended reaches a disposed stream. It has to be as ordinary as pressing one
|
||||||
|
/// at a tab that has closed, which is to say nothing happens.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SendingInputToASessionWhoseRemoteHasExited_IsIgnored()
|
||||||
|
{
|
||||||
|
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||||
|
|
||||||
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => !workspace.IsSessionLive(sessionId));
|
||||||
|
|
||||||
|
await Should.NotThrowAsync(async () =>
|
||||||
|
await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Closing a tab over a session that already released itself must still work, because that is the
|
||||||
|
/// ordinary way a dead tab goes away. Everything the close does is idempotent by construction — the
|
||||||
|
/// entry is removed under the lock, and both disposals are guarded — and this is the test that says so
|
||||||
|
/// rather than leaving it to be inferred from three separate classes.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClosingATabOverASessionThatAlreadyEnded_IsStillSafe()
|
||||||
|
{
|
||||||
|
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||||
|
|
||||||
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => !workspace.IsSessionLive(sessionId));
|
||||||
|
|
||||||
|
await Should.NotThrowAsync(() => workspace.CloseSessionAsync(sessionId));
|
||||||
|
|
||||||
|
workspace.CreditsFor(sessionId).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
/// <param name="connections">
|
/// <param name="connections">
|
||||||
/// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they
|
/// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they
|
||||||
/// take a fake anyway, because reaching a real host from here would make this a network test.
|
/// take a fake anyway, because reaching a real host from here would make this a network test.
|
||||||
@@ -630,11 +779,20 @@ public sealed class TerminalWorkspaceTests
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <c>WaitForRendererAsync</c> answers only for the first renderer ever to attach, so a second call
|
/// <c>WaitForRendererAsync</c> answers only for the first renderer ever to attach, so a second call
|
||||||
/// returns immediately without proving anything about the second socket. That is enough here and is
|
/// returns immediately without proving anything about the second socket — and even for the first it
|
||||||
/// not luck: the only frames a second socket is given are the replay, and the replay is *caused* by the
|
/// answers too early for what the callers here need. It completes when the socket is attached, which
|
||||||
/// attach — <see cref="TerminalWorkspace.RendererReattached"/> and the frames before it cannot be sent
|
/// is before <see cref="TerminalWorkspace.ReplayAfterAttachAsync"/> has run: that is fired and
|
||||||
/// early. The day a test sends something else down a reattached socket, this needs the data plane's own
|
/// forgotten from the accept thread, and a thread-pool that deschedules it for a timeslice lets the
|
||||||
/// <c>SocketAttached</c>, which the workspace does not forward today.
|
/// test open a session in the meantime. The replay then finds that session live and sends it a second,
|
||||||
|
/// replay-flagged <c>SessionOpened</c> — harmless to the page, which treats it as idempotent, but it
|
||||||
|
/// lands in the middle of the frames the test is counting on.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ SO WAIT FOR THE REPLAY TO BE OVER, NOT ONLY FOR THE ATTACH. <c>RendererReattached</c> is raised
|
||||||
|
/// as the last thing the replay does, so a socket this returns has had every frame the attach itself
|
||||||
|
/// causes, and nothing but what the test does next can put another on it. Subscribed before the
|
||||||
|
/// upgrade, because the replay for a socket that attaches quickly can be over before this method gets
|
||||||
|
/// to look.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
|
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
|
||||||
@@ -652,17 +810,29 @@ public sealed class TerminalWorkspaceTests
|
|||||||
"Origin",
|
"Origin",
|
||||||
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{workspace.PageUrl.Port}"));
|
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{workspace.PageUrl.Port}"));
|
||||||
|
|
||||||
|
var replayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
void OnReattached(object? sender, EventArgs e) => replayed.TrySetResult();
|
||||||
|
|
||||||
|
workspace.RendererReattached += OnReattached;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
|
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
|
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await replayed.Task.WaitAsync(ReplayTimeout, TestContext.Current.CancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
client.Dispose();
|
client.Dispose();
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
workspace.RendererReattached -= OnReattached;
|
||||||
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user