Public Access
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:
|
||||
name: build and test
|
||||
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:
|
||||
# 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
|
||||
@@ -220,7 +232,12 @@ jobs:
|
||||
- name: build
|
||||
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
|
||||
timeout-minutes: 10
|
||||
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
||||
|
||||
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
||||
@@ -556,6 +573,8 @@ jobs:
|
||||
exit 0
|
||||
android:
|
||||
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]
|
||||
# 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.
|
||||
@@ -915,7 +934,10 @@ jobs:
|
||||
# never made.
|
||||
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||
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
|
||||
printf '%s\n' "$published" >&2
|
||||
exit 1
|
||||
@@ -926,6 +948,8 @@ jobs:
|
||||
|
||||
desktop:
|
||||
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
|
||||
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
|
||||
# commit whose suite was red.
|
||||
@@ -1226,7 +1250,10 @@ jobs:
|
||||
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
|
||||
|
||||
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
|
||||
printf '%s\n' "$published" >&2
|
||||
exit 1
|
||||
@@ -1279,7 +1306,8 @@ jobs:
|
||||
# upload it never made.
|
||||
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||
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
|
||||
printf '%s\n' "$withRpm" >&2
|
||||
exit 1
|
||||
@@ -1290,6 +1318,8 @@ jobs:
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -69,7 +69,8 @@ src/
|
||||
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.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.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
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"post.logout.redirect.uris": "http://127.0.0.1/*"
|
||||
},
|
||||
"redirectUris": [
|
||||
"http://127.0.0.1/callback"
|
||||
"http://127.0.0.1/callback",
|
||||
"dev.dodotech.dodossh:/callback"
|
||||
],
|
||||
"webOrigins": [],
|
||||
"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
|
||||
> 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
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
`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.
|
||||
|
||||
## 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.
|
||||
+375
-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
|
||||
|
||||
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings**. Then repeat from a
|
||||
fresh open, choosing **Vaults**, and again choosing **Preferences**.
|
||||
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings** — which is the menu's
|
||||
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
|
||||
application" bar, the ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the
|
||||
update banner (if either was showing) are both gone. **Settings** lands on General; **Vaults** and
|
||||
**Preferences** land on their own pages, each lit in SettingsNav's own list — General/Vaults/Account under
|
||||
SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below both. Click every one of the
|
||||
seven rows in turn: each swaps the content column without leaving settings mode or touching the titlebar.
|
||||
**Pass:** it swaps the whole window's chrome — the titlebar becomes the 53px "Back to application" bar, the
|
||||
ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the update banner (if either
|
||||
was showing) are both gone. It lands on **General**, with SettingsNav's own list beside it —
|
||||
General/Vaults/Account under SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below
|
||||
both. Click every one of the seven rows in turn: each swaps the content column without leaving settings mode
|
||||
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
|
||||
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,
|
||||
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
|
||||
**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
|
||||
`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.
|
||||
|
||||
**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 —
|
||||
`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
|
||||
@@ -197,6 +202,88 @@ 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
|
||||
currently see — see the note at the end of 11.12a.
|
||||
|
||||
### 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
|
||||
@@ -360,6 +447,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
|
||||
|
||||
**This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native
|
||||
@@ -1687,6 +1842,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
|
||||
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
|
||||
|
||||
Focus a passphrase box, then turn the phone sideways.
|
||||
@@ -1840,12 +2016,22 @@ Now tap **Tab** on the accessory row, or **A+**, and type again.
|
||||
keyboard.
|
||||
|
||||
**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
|
||||
page's focus. The row goes on working, because its keys are pressed rather than typed into, so the symptom
|
||||
is a terminal that answers the buttons and ignores the keyboard: it reads as the session having died. See
|
||||
`Focusable = false` in `TerminalScreen.axaml.cs` and the focus entry in `docs/platform-flags.md`.
|
||||
focus on tap, which takes it off `TerminalInputSurface`. The row goes on working, because its keys are
|
||||
pressed rather than typed into, so the symptom is a terminal that answers the buttons and ignores the
|
||||
keyboard: it reads as the session having died. See `Focusable = false` in `TerminalScreen.axaml.cs`.
|
||||
|
||||
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.
|
||||
|
||||
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
|
||||
@@ -1853,18 +2039,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
|
||||
keep typing on the software keyboard.
|
||||
|
||||
**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
|
||||
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
|
||||
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
|
||||
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
|
||||
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
|
||||
**Pass:** the keyboard settles back unchanged — same layout, same height — and everything typed after the
|
||||
tap still reaches the terminal. The accessory row stays visible above the keyboard throughout. There should
|
||||
be **no blink at all** now: under [ADR 0015](adr/0015-android-terminal-input.md) the input target is an
|
||||
Avalonia control, so the focus the platform hands to Avalonia's own view after every dispatched touch is
|
||||
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
|
||||
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
|
||||
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
|
||||
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
|
||||
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
|
||||
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
|
||||
**Failure means:** the buttons on that row have become focusable again, taking Avalonia's focus off
|
||||
`TerminalInputSurface`. The old failure — a keyboard that swapped layouts and parked itself over the row —
|
||||
belonged to the arrangement where the `WebView` held the input connection and is not reachable from here;
|
||||
if it reappears, the renderer has taken focus back and `RendererFocus.Suppress` did not run. See check
|
||||
11.10c.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -1922,6 +2188,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
|
||||
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
|
||||
@@ -2565,6 +2851,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
|
||||
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
|
||||
|
||||
Immediately after 16.7, connect to a host.
|
||||
@@ -2610,6 +2922,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
|
||||
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
|
||||
|
||||
+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
|
||||
# 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")"
|
||||
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)"
|
||||
unsatisfiable=""
|
||||
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"
|
||||
fi
|
||||
done
|
||||
@@ -251,7 +270,7 @@ fi
|
||||
# 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
|
||||
# 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
|
||||
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&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
|
||||
# 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.
|
||||
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 "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
|
||||
printf '%s\n' "$requires" >&2
|
||||
@@ -279,9 +298,9 @@ for expected in \
|
||||
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
||||
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
||||
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
|
||||
printf '%s\n' "$files" | head -40 >&2
|
||||
printf '%s\n' "$files" | awk 'NR<=40' >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -86,7 +86,13 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
var shell = new PhoneShell();
|
||||
|
||||
var viewModel = ComposeShell(
|
||||
paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell));
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
connections,
|
||||
ClipboardWriter(shell),
|
||||
ClipboardReader(shell));
|
||||
|
||||
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>
|
||||
/// 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.
|
||||
@@ -186,7 +212,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
TerminalWorkspace workspace,
|
||||
VaultKnownHostStore knownHosts,
|
||||
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
|
||||
// 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,
|
||||
|
||||
copyToClipboard: copyToClipboard,
|
||||
readClipboard: readClipboard,
|
||||
|
||||
updates: updates);
|
||||
|
||||
|
||||
@@ -90,6 +90,11 @@ public sealed class MainActivity : AvaloniaMainActivity
|
||||
PhoneEnvironment.CurrentActivity = this;
|
||||
|
||||
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 />
|
||||
|
||||
@@ -100,6 +100,35 @@ internal static class PhoneEnvironment
|
||||
/// </remarks>
|
||||
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>
|
||||
public static Context Require() =>
|
||||
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="FontSize" Value="20" />
|
||||
</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">
|
||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||
</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 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" />
|
||||
</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
|
||||
times because the two attributes are not interchangeable and both are needed.
|
||||
@@ -473,10 +639,29 @@
|
||||
<Setter Property="FontSize" Value="9.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</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">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.body">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
|
||||
@@ -551,7 +551,7 @@
|
||||
Foreground="{StaticResource Accent}"
|
||||
Background="{StaticResource Raised}" />
|
||||
<TextBlock Classes="detail" Text="{Binding Progress}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -49,12 +49,29 @@
|
||||
|
||||
<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">
|
||||
<Run Text="First contact with" />
|
||||
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
||||
<Run Text="·" />
|
||||
<Run Text="{Binding PendingHostKey.Port}" />
|
||||
<Run Text=". Nothing in this keychain has approved this key." />
|
||||
<Run Text="{Binding PendingHostKey.Port, StringFormat='{}{0}.'}" />
|
||||
<Run Text="Nothing in this keychain has approved this key." />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<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="{Binding Authentication}" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- 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>
|
||||
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
|
||||
@@ -106,11 +106,11 @@
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<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
|
||||
@@ -124,7 +124,7 @@
|
||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
Text="·" />
|
||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding DeviceName}" TextTrimming="CharacterEllipsis" />
|
||||
Text="{Binding DeviceName}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- A refused host key is the one outcome worth a colour of its own. -->
|
||||
@@ -158,7 +158,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<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. -->
|
||||
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasChangedFields}"
|
||||
|
||||
@@ -391,11 +391,15 @@ internal sealed partial class PhoneShell : UserControl
|
||||
ApplyKeyboardInset(keyboard);
|
||||
}
|
||||
}
|
||||
|
||||
PhoneEnvironment.Resumed += OnActivityResumed;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
PhoneEnvironment.Resumed -= OnActivityResumed;
|
||||
|
||||
if (TopLevel.GetTopLevel(this) is { } top)
|
||||
{
|
||||
top.BackRequested -= OnBackRequested;
|
||||
@@ -410,6 +414,43 @@ internal sealed partial class PhoneShell : UserControl
|
||||
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)
|
||||
=> 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." />
|
||||
<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." />
|
||||
<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"
|
||||
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
|
||||
</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"
|
||||
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
|
||||
VerticalAlignment="Center" Text="{Binding Started}" />
|
||||
@@ -471,6 +471,29 @@
|
||||
design's #171A26. See WebAssets/terminal.css.
|
||||
-->
|
||||
<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>
|
||||
|
||||
<!-- ============ 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. -->
|
||||
<!--
|
||||
33 tall rather than 50, a third off it like the bar at the top, with the keys inside coming down from
|
||||
38 to 30 for the reason the pills did: a row that shrank around its own contents would clip them.
|
||||
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
|
||||
|
||||
The five pixels above it 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 this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
|
||||
and it was wrong in two ways that compounded each other.
|
||||
|
||||
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
|
||||
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
|
||||
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
|
||||
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
|
||||
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
|
||||
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
|
||||
to be the first thing anybody noticed about this row.
|
||||
|
||||
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
|
||||
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
|
||||
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
|
||||
which is a trade worth making right up until the control stops working, and 30 is past that.
|
||||
|
||||
── what the other clients do, because all of them agree ─────────────────────────────────────────────
|
||||
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
|
||||
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
|
||||
second row — Termux ships two by default. That is not convention for its own sake: a key whose
|
||||
position is fixed can be hit without looking, which is the entire point of a modifier row you use
|
||||
while reading the output above it. A key that might have scrolled has to be found first.
|
||||
|
||||
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
|
||||
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
|
||||
ones that benefit, because the arrows were the keys living past the right edge.
|
||||
|
||||
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
|
||||
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
|
||||
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
|
||||
Ctrl is not the better surface.
|
||||
|
||||
── the split ────────────────────────────────────────────────────────────────────────────────────────
|
||||
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
|
||||
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
|
||||
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
|
||||
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
|
||||
|
||||
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
|
||||
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of
|
||||
its own — and a terminal whose last line of output is flush against a row of grey keys reads as one
|
||||
surface that has gone wrong rather than as two that are different things.
|
||||
-->
|
||||
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||
<!--
|
||||
@@ -539,62 +599,70 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
|
||||
VerticalAlignment="Center" />
|
||||
</ScrollViewer>
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
|
||||
<!--
|
||||
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
|
||||
beside them.
|
||||
|
||||
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. 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.
|
||||
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
|
||||
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
|
||||
between them instead of taking what their labels happen to need — "ctrl" is four characters and
|
||||
"|" is one, and on a row of keys those two want the same target, not proportional ones.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
|
||||
VerticalAlignment="Center" />
|
||||
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
|
||||
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" Focusable="False"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
<!--
|
||||
The cursor keys, and the two text-size buttons riding at the end.
|
||||
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" Focusable="False"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
|
||||
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
|
||||
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
|
||||
them every time the row's contents changed.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
||||
|
||||
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
|
||||
|
||||
<!--
|
||||
The text-size buttons.
|
||||
|
||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the
|
||||
state in which hunting for the control that fixes it is worst. Nothing on this keypad scrolls
|
||||
any more, so that argument is now satisfied by construction rather than by placement.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in
|
||||
this screen.
|
||||
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
|
||||
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
||||
TerminalScreen.axaml.cs. Every button on this keypad sits over a terminal somebody is typing
|
||||
into, and an ordinary one takes Avalonia's focus off the input surface on tap. Making the text
|
||||
smaller must not cost the keyboard.
|
||||
-->
|
||||
<!-- No Spacing: Button.hotkey carries a 2-pixel side margin, so the gaps here match the
|
||||
UniformGrid cells' without being counted twice. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="8,0,0,0">
|
||||
<Border Width="1" Height="20" Background="{StaticResource Border}" Margin="0,0,4,0"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
|
||||
using DodoSSH.Client.Android.Platform;
|
||||
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.
|
||||
/// </para>
|
||||
/// </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),
|
||||
("tab", [0x09], false),
|
||||
@@ -46,12 +56,31 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
("-", [(byte)'-'], false),
|
||||
("~", [(byte)'~'], false),
|
||||
("/", [(byte)'/'], false),
|
||||
];
|
||||
|
||||
// The cursor keys, as 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.
|
||||
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
||||
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
||||
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The ANSI sequences a PTY expects, written out rather than composed, because the difference between
|
||||
/// CSI A and SS3 A is the difference between working in bash and not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Left, down, up, right</b> — the order every other client on the platform puts them in, and not
|
||||
/// the order they were in here. On one flat row the pairs have to read as pairs: horizontal at the ends
|
||||
/// and vertical in the middle means the two keys that undo each other are always neighbours, and a
|
||||
/// thumb learns the row's shape rather than its labels. Up-down-left-right is the order they are named
|
||||
/// in, which is a different thing from the order they are used in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// These four were the keys that used to live past the right-hand edge of the scroller — the most
|
||||
/// pressed keys on the keypad, and the ones you had to go looking for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly (string Label, byte[] Bytes, bool Latches)[] CursorKeys =
|
||||
[
|
||||
("←", [0x1B, (byte)'[', (byte)'D'], false),
|
||||
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
||||
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
||||
("→", [0x1B, (byte)'[', (byte)'C'], false),
|
||||
];
|
||||
|
||||
@@ -67,26 +96,119 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
|
||||
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()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
BuildAccessoryRow();
|
||||
|
||||
input = this.FindControl<TerminalInputSurface>("Input")!;
|
||||
input.Input += (_, e) => SendAsync(e.Bytes);
|
||||
|
||||
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.
|
||||
DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (subscribed is { } previous)
|
||||
{
|
||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||
subscribed = null;
|
||||
}
|
||||
|
||||
if (DataContext is MainWindowViewModel shell)
|
||||
{
|
||||
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>
|
||||
/// Puts the keyboard away on the way to the connect menu.
|
||||
/// </summary>
|
||||
@@ -137,18 +259,26 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
|
||||
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
|
||||
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
|
||||
// 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);
|
||||
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
|
||||
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -171,49 +301,31 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
private static Button CreateKey(string label) =>
|
||||
new()
|
||||
{
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
Content = label,
|
||||
|
||||
// 44 wide, and that is the number that matters: the design draws these flexed across the
|
||||
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
|
||||
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
|
||||
// costs nothing a width does: the keys are a single row with the terminal above and the
|
||||
// system's gesture bar below, so there is no neighbour a short press can land on instead.
|
||||
MinWidth = 44,
|
||||
Height = 30,
|
||||
Padding = new Thickness(10, 0),
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Background = Palette("Panel"),
|
||||
BorderBrush = Palette("BorderMid"),
|
||||
BorderThickness = new Thickness(1),
|
||||
Foreground = Palette("TextDim"),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
// ◆ Every dimension and brush this used to set by hand is now Button.hotkey in Phone.axaml,
|
||||
// and the move is what lets the row have a pressed state at all: a style can say :pressed and
|
||||
// an assignment cannot. It also takes this file out of the business of naming the palette's
|
||||
// brushes, which is how a keypad ends up with a grey nothing else uses.
|
||||
//
|
||||
// Content is the string rather than a TextBlock built here for the same reason — the class
|
||||
// carries the font and the alignment, so a wrapper element would only be somewhere for them to
|
||||
// disagree.
|
||||
Classes = { "hotkey" },
|
||||
|
||||
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
||||
//
|
||||
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
||||
// ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView
|
||||
// — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing
|
||||
// Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next
|
||||
// thing typed on a hardware keyboard went nowhere, and the row went on working because its
|
||||
// buttons are pressed rather than typed into, which is what makes it look like the terminal
|
||||
// had died instead.
|
||||
// ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
|
||||
// terminal's input target is — and a modifier row that dismisses the keyboard every time it
|
||||
// is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
|
||||
// the focused element never changes: TerminalInputSurface is still it, so the input
|
||||
// connection is never torn down and nothing has to be handed back.
|
||||
//
|
||||
// Focusable=false is what a toolbar button is, and it means the focused element never
|
||||
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
||||
//
|
||||
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
|
||||
// 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.
|
||||
// This used to be only half a fix, because the target was the NativeWebView and Android keeps
|
||||
// a focus of its own that the platform hands to Avalonia's view after every touch it
|
||||
// dispatches. That half is gone rather than countered: under ADR 0015 the input target is an
|
||||
// Avalonia control, so the focus Android moves is already where the keyboard should be.
|
||||
Focusable = false,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
// ^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>
|
||||
/// 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
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<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
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Auth;
|
||||
@@ -33,12 +34,77 @@ internal sealed partial class DodoSshApp : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Compose(desktop);
|
||||
ShowSomethingWhileComposing(desktop);
|
||||
}
|
||||
|
||||
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>
|
||||
/// <para>
|
||||
/// 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>
|
||||
/// <remarks>
|
||||
/// 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 };
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
_ = 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>
|
||||
@@ -169,6 +278,7 @@ internal sealed partial class DodoSshApp : Application
|
||||
.ConfigureAwait(false),
|
||||
|
||||
copyToClipboard: ClipboardWriter(desktop),
|
||||
readClipboard: ClipboardReader(desktop),
|
||||
updates: updates,
|
||||
openInEditor: OpenInDefaultEditorAsync,
|
||||
watchFile: WatchFile);
|
||||
@@ -259,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
MainWindowViewModel viewModel,
|
||||
TerminalWorkspace workspace,
|
||||
ClientCacheFactory caches)
|
||||
ClientCacheFactory caches,
|
||||
IAsyncDisposable others)
|
||||
{
|
||||
var shuttingDown = false;
|
||||
|
||||
@@ -278,6 +389,11 @@ internal sealed partial class DodoSshApp : Application
|
||||
await viewModel.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();
|
||||
|
||||
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>
|
||||
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;
|
||||
|
||||
/// <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>,
|
||||
// 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 />
|
||||
@@ -335,21 +370,39 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <see cref="CheckAsync"/> returned; a mismatch is a bug in this application rather than anything a
|
||||
/// user did, which is why it throws rather than resolving to some safe-looking default.
|
||||
/// <see cref="CheckAsync"/> returned, or what a download of that then fetched; a mismatch is a bug in
|
||||
/// 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>
|
||||
private UpdateInfo Matched(AvailableUpdate update)
|
||||
{
|
||||
if (found is not { } info
|
||||
|| !string.Equals(info.TargetFullRelease.Version.ToString(), update.Version, StringComparison.Ordinal))
|
||||
if (Names(fetched, update))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No update matching {update.Version} has been found by this channel. "
|
||||
+ "Call CheckAsync and pass back what it returned.");
|
||||
return fetched!;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
/*
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,13 +111,33 @@
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
||||
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">
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||
HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding SelectedTab.Label}" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding SelectedTab.Address}" />
|
||||
</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
|
||||
nowhere.
|
||||
|
||||
◆ EVERY GROUP, ONE AFTER ANOTHER, "No group" FIRST. Binds HostSections rather than VisibleHosts:
|
||||
the old grid held one level of a tree at a time, and this holds every group's cards at once, the
|
||||
way SidebarRows always has for the phone. A keychain nobody has filed anything in draws one
|
||||
section with no heading at all — see HostSectionViewModel.HasHeader — which is what makes the
|
||||
feature invisible until it is used.
|
||||
◆ EVERY VAULT, AND INSIDE EACH ONE EVERY GROUP, "No group" FIRST. Binds HostSections rather
|
||||
than VisibleHosts: the old grid held one level of a tree at a time, and this holds every group's
|
||||
cards at once, the way SidebarRows always has for the phone. A keychain nobody has filed
|
||||
anything in draws one section with no heading at all — see HostSectionViewModel.HasHeader —
|
||||
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.ItemsPanel>
|
||||
@@ -472,6 +473,27 @@
|
||||
<DataTemplate x:DataType="vm:HostSectionViewModel">
|
||||
<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 ============
|
||||
"{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}" />
|
||||
<MenuItem Header="Edit…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||
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}" />
|
||||
<MenuItem Header="Delete…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||
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
|
||||
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,
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
◆ 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"
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="104"
|
||||
ToolTip.Tip="{Binding Tooltip}">
|
||||
<Panel>
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
|
||||
@@ -263,7 +263,9 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
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;
|
||||
|
||||
Board.Focus();
|
||||
|
||||
@@ -252,13 +252,23 @@
|
||||
<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 —
|
||||
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,
|
||||
which is also what anything that used to navigate to ShellScreen.Vaults or
|
||||
ShellScreen.Preferences now does — see ShowScreen. Three handlers rather than the two rows
|
||||
sharing one before this wave: the mock's Settings area was a family of screens that did not
|
||||
exist yet, and now that it does, "Settings" and "Preferences" are no longer the same click.
|
||||
◆ ONE ROW, WHERE v5c PUT THREE.
|
||||
|
||||
Settings, Vaults and Preferences all opened the same full-window settings mode — see
|
||||
MainWindowViewModel.EnterSettings and SettingsView.axaml — differing only in which of its
|
||||
seven pages they landed on. Two of the three were therefore deep links into a place the third
|
||||
one opens, and SettingsNav is already on screen the whole time listing every page including
|
||||
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">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
@@ -268,29 +278,6 @@
|
||||
</StackPanel>
|
||||
</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}" />
|
||||
|
||||
<!--
|
||||
|
||||
@@ -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>
|
||||
/// Starts a sign-out, through the Account settings page so the confirmation card has somewhere to be
|
||||
/// seen — see <see cref="MainWindowViewModel.SignOutFromPopover"/>.
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Check on its own" />
|
||||
<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>
|
||||
<CheckBox Grid.Column="1" VerticalAlignment="Top"
|
||||
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,
|
||||
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
|
||||
card (clipboard-clear delay, confirm-run-on-insert) has nothing behind either row and is not drawn here.
|
||||
Sync now/Sign in, and the importer rows; the design's own SECURITY card (clipboard-clear delay,
|
||||
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>
|
||||
@@ -95,7 +97,7 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settingsrow last">
|
||||
<Border Classes="settingsrow">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Import SSH config" />
|
||||
@@ -108,6 +110,24 @@
|
||||
</Grid>
|
||||
</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>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -17,9 +17,12 @@
|
||||
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
|
||||
MainWindowViewModel.IsImportOpen — 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
|
||||
MainWindowViewModel.IsImporterOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in
|
||||
place of "Back to application" while an importer is up, and CloseImportCommand closes it without leaving
|
||||
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}"
|
||||
@@ -28,7 +31,7 @@
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
||||
|
||||
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding !IsImportOpen}"
|
||||
IsVisible="{Binding !IsImporterOpen}"
|
||||
Command="{Binding LeaveSettingsCommand}"
|
||||
ToolTip.Tip="Back to the screen you were on before opening Settings">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
@@ -40,7 +43,7 @@
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding IsImportOpen}"
|
||||
IsVisible="{Binding IsImporterOpen}"
|
||||
Command="{Binding CloseImportCommand}"
|
||||
ToolTip.Tip="Back to preferences, without leaving Settings">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
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
|
||||
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}">
|
||||
@@ -59,6 +64,11 @@
|
||||
<views:ImportScreen DataContext="{Binding ImportScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- The Hetzner wizard, on exactly the same terms. See MainWindowViewModel.IsHetznerImportOpen. -->
|
||||
<Panel IsVisible="{Binding IsHetznerImportOpen}">
|
||||
<views:HetznerImportScreen DataContext="{Binding HetznerImportScreen}" />
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
</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">
|
||||
|
||||
<!--
|
||||
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
|
||||
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
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
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>
|
||||
/// <remarks>
|
||||
/// 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 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
|
||||
// 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"/>.
|
||||
/// </remarks>
|
||||
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>
|
||||
@@ -328,6 +339,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
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']" />
|
||||
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
|
||||
/// the Android head's distribution separately, so it must never acquire one by accident.
|
||||
/// </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">
|
||||
/// 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
|
||||
@@ -527,12 +563,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Argon2Profile? passphraseProfile = null,
|
||||
ResumeHandler? resume = null,
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
Func<Task<string?>>? readClipboard = null,
|
||||
string? deviceName = null,
|
||||
IUpdateChannel? updates = null,
|
||||
Action<Action>? post = 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.paths = paths;
|
||||
@@ -545,6 +584,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
this.copyToClipboard = copyToClipboard;
|
||||
this.readClipboard = readClipboard;
|
||||
|
||||
// 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
|
||||
@@ -575,6 +615,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
|
||||
this.workspace.ClipboardCopyRequested += OnClipboardCopyRequested;
|
||||
this.workspace.ClipboardPasteRequested += OnClipboardPasteRequested;
|
||||
this.workspace.RendererReattached += OnRendererReattached;
|
||||
|
||||
settings = new ClientSettingsStore(paths);
|
||||
@@ -726,6 +768,106 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
|
||||
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>
|
||||
/// <para>
|
||||
/// 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]
|
||||
private ImportViewModel? importScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private HetznerImportViewModel? hetznerImportScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private SnippetsViewModel? snippetsScreen;
|
||||
@@ -1414,6 +1560,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return;
|
||||
}
|
||||
|
||||
if (target is ShellScreen.HetznerImport)
|
||||
{
|
||||
OpenHetznerImport();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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".
|
||||
ActiveSettingsPage = null;
|
||||
@@ -1476,10 +1628,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private bool isImportOpen;
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImportOpen;
|
||||
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImporterOpen;
|
||||
|
||||
partial void OnActiveSettingsPageChanged(SettingsPage? value)
|
||||
{
|
||||
@@ -1494,8 +1668,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary>
|
||||
/// <remarks>
|
||||
@@ -1518,6 +1700,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
ActiveSettingsPage = page;
|
||||
IsImportOpen = false;
|
||||
IsHetznerImportOpen = false;
|
||||
|
||||
Screen = page switch
|
||||
{
|
||||
@@ -1539,9 +1722,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
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]
|
||||
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>
|
||||
[RelayCommand]
|
||||
@@ -1554,6 +1760,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
ActiveSettingsPage = null;
|
||||
IsImportOpen = false;
|
||||
IsHetznerImportOpen = false;
|
||||
Screen = settingsReturnScreen;
|
||||
Surface = settingsReturnSurface;
|
||||
}
|
||||
@@ -2678,7 +2885,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
ReconnectAsync,
|
||||
copyToClipboard,
|
||||
connectionLog,
|
||||
visibility);
|
||||
visibility,
|
||||
reachability);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
// 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.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||
workspace.ClipboardCopyRequested -= OnClipboardCopyRequested;
|
||||
workspace.ClipboardPasteRequested -= OnClipboardPasteRequested;
|
||||
workspace.RendererReattached -= OnRendererReattached;
|
||||
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
||||
|
||||
@@ -3434,6 +3644,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
? null
|
||||
: 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 = newValue is null
|
||||
? null
|
||||
|
||||
@@ -74,6 +74,22 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
/// </remarks>
|
||||
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 ClientSettingsStore settings;
|
||||
private readonly TimeProvider clock;
|
||||
@@ -143,6 +159,18 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
[ObservableProperty]
|
||||
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 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 IsBannerShowing => IsReady && !IsBannerDismissed;
|
||||
internal bool IsBannerShowing => (IsReady || IsRestarting) && !IsBannerDismissed;
|
||||
|
||||
/// <summary>What the banner says.</summary>
|
||||
internal string ReadyHeadline => ReadyVersion is { Length: > 0 } version
|
||||
? $"DodoSSH {version} is ready to install."
|
||||
: "An update is ready to install.";
|
||||
/// <remarks>
|
||||
/// Three sentences rather than one, because a restart no longer necessarily starts with a build in hand:
|
||||
/// 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>
|
||||
/// <remarks>
|
||||
@@ -201,10 +254,24 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
OnPropertyChanged(nameof(CanCheckNow));
|
||||
OnPropertyChanged(nameof(IsBannerShowing));
|
||||
OnPropertyChanged(nameof(RestartWarning));
|
||||
OnPropertyChanged(nameof(ReadyHeadline));
|
||||
}
|
||||
|
||||
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 OnLastCheckedChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastCheckedSummary));
|
||||
@@ -278,14 +345,32 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
|
||||
/// <summary>One pass of the loop.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <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,
|
||||
/// 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.
|
||||
/// </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>
|
||||
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;
|
||||
}
|
||||
@@ -339,27 +424,91 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Checks, and fetches whatever it finds.</summary>
|
||||
/// <returns>The update found, or null.</returns>
|
||||
private async Task<AvailableUpdate?> FetchAsync(CancellationToken cancellationToken)
|
||||
/// <summary>Checks, and fetches whatever it finds that is not already fetched.</summary>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <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;
|
||||
Status = string.Empty;
|
||||
|
||||
var found = await updates.CheckAsync(cancellationToken).ConfigureAwait(true);
|
||||
var found = await AskAsync(checkWithin, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
LastChecked = clock.GetUtcNow();
|
||||
|
||||
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;
|
||||
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
|
||||
// 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
|
||||
@@ -382,13 +531,43 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
DownloadPercent = 100;
|
||||
ready = found;
|
||||
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;
|
||||
State = UpdateState.Ready;
|
||||
Status = $"DodoSSH {found.Version} is downloaded and will run after a restart.";
|
||||
Status = Waiting(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>
|
||||
/// <para>
|
||||
/// 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]
|
||||
private async Task RestartNowAsync()
|
||||
{
|
||||
if (ready is not { } update)
|
||||
if (ready is null)
|
||||
{
|
||||
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.
|
||||
IsRestarting = true;
|
||||
|
||||
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)
|
||||
{
|
||||
State = UpdateState.Failed;
|
||||
Status = exception.Message;
|
||||
// Including the bound running out. Back to the offer that was already on screen.
|
||||
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>
|
||||
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>
|
||||
internal string Chevron => IsExpanded ? "▾" : "▸";
|
||||
|
||||
@@ -167,6 +183,31 @@ internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnly
|
||||
internal bool HasHeader => header is not null;
|
||||
|
||||
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>
|
||||
@@ -411,6 +452,19 @@ internal sealed partial class HostRowViewModel(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{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>
|
||||
/// 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,
|
||||
@@ -1391,8 +1445,18 @@ internal sealed partial class VaultViewModel(
|
||||
ServerReconnectHandler? reconnect = null,
|
||||
Func<string, Task>? copyToClipboard = 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>
|
||||
/// <para>
|
||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
||||
@@ -5170,25 +5234,109 @@ internal sealed partial class VaultViewModel(
|
||||
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()
|
||||
{
|
||||
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)
|
||||
{
|
||||
return [];
|
||||
return sections;
|
||||
}
|
||||
|
||||
if (Groups.Count == 0)
|
||||
{
|
||||
return [new HostSectionViewModel(null, [.. flat.Cast<HostRowViewModel>()])];
|
||||
}
|
||||
|
||||
var sections = new List<HostSectionViewModel>();
|
||||
SidebarGroupHeader? header = null;
|
||||
var members = new List<HostRowViewModel>();
|
||||
var firstHeading = true;
|
||||
var loose = new List<HostRowViewModel>();
|
||||
|
||||
foreach (var row in flat)
|
||||
{
|
||||
@@ -5199,15 +5347,12 @@ internal sealed partial class VaultViewModel(
|
||||
sections.Add(new HostSectionViewModel(header, members));
|
||||
}
|
||||
|
||||
// Marked on the first heading only, which is where the mock draws Collapse all / Expand
|
||||
// all — see IsFirstBoardSection and the XAML.
|
||||
header = firstHeading ? next with { IsFirstBoardSection = true } : next;
|
||||
firstHeading = false;
|
||||
header = vaultHeading is null ? next : next with { VaultBadge = string.Empty };
|
||||
members = [];
|
||||
}
|
||||
else if (row is HostRowViewModel host)
|
||||
{
|
||||
members.Add(host);
|
||||
(header is null ? loose : members).Add(host);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5215,6 +5360,18 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -5230,6 +5387,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.
|
||||
/// </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>
|
||||
/// <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
|
||||
@@ -5240,17 +5403,27 @@ internal sealed partial class VaultViewModel(
|
||||
/// left empty.
|
||||
/// </remarks>
|
||||
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>();
|
||||
|
||||
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);
|
||||
return rows;
|
||||
}
|
||||
|
||||
var known = Groups.Select(group => group.EntityId).ToHashSet();
|
||||
var known = groups.Select(group => group.EntityId).ToHashSet();
|
||||
|
||||
void AddUngrouped() => AddFlatSection(
|
||||
rows,
|
||||
@@ -5258,14 +5431,15 @@ internal sealed partial class VaultViewModel(
|
||||
null,
|
||||
host => host.Host.GroupId is not { } id || !known.Contains(id),
|
||||
ungroupedLabel,
|
||||
onlyWhenOccupied: true);
|
||||
onlyWhenOccupied: true,
|
||||
ungroupedKey: vaultId ?? Guid.Empty);
|
||||
|
||||
if (ungroupedFirst)
|
||||
{
|
||||
AddUngrouped();
|
||||
}
|
||||
|
||||
foreach (var group in Groups)
|
||||
foreach (var group in groups)
|
||||
{
|
||||
AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel);
|
||||
}
|
||||
@@ -5289,13 +5463,19 @@ internal sealed partial class VaultViewModel(
|
||||
/// <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="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(
|
||||
List<ISidebarRow> rows,
|
||||
IReadOnlyList<HostRowViewModel> shown,
|
||||
HostGroupRowViewModel? group,
|
||||
Func<HostRowViewModel, bool> belongs,
|
||||
string ungroupedLabel,
|
||||
bool onlyWhenOccupied = false)
|
||||
bool onlyWhenOccupied = false,
|
||||
Guid ungroupedKey = default)
|
||||
{
|
||||
var members = shown.Where(belongs).ToArray();
|
||||
|
||||
@@ -5304,7 +5484,8 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty);
|
||||
var key = group?.EntityId ?? ungroupedKey;
|
||||
var expanded = !collapsedGroups.Contains(key);
|
||||
|
||||
rows.Add(new SidebarGroupHeader(
|
||||
group?.EntityId,
|
||||
@@ -5313,6 +5494,7 @@ internal sealed partial class VaultViewModel(
|
||||
expanded)
|
||||
{
|
||||
VaultBadge = group?.VaultBadge ?? string.Empty,
|
||||
CollapseKey = key,
|
||||
});
|
||||
|
||||
if (!expanded)
|
||||
@@ -5458,7 +5640,7 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
var key = header.GroupId ?? Guid.Empty;
|
||||
var key = header.CollapseKey;
|
||||
|
||||
if (!collapsedGroups.Remove(key))
|
||||
{
|
||||
@@ -5490,7 +5672,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
foreach (var header in headers)
|
||||
{
|
||||
var key = header.GroupId ?? Guid.Empty;
|
||||
var key = header.CollapseKey;
|
||||
|
||||
if (collapsing)
|
||||
{
|
||||
@@ -6259,6 +6441,69 @@ internal sealed partial class VaultViewModel(
|
||||
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>
|
||||
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
|
||||
/// </summary>
|
||||
@@ -6288,6 +6533,14 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -8283,12 +8536,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
|
||||
/// too.
|
||||
/// </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>
|
||||
/// <para>
|
||||
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
|
||||
/// through the same repositories, the same outbox and the same automatic push as saving one — the import
|
||||
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host or a key to
|
||||
/// be written and no second place for the sync wiring to be forgotten.
|
||||
/// For the two importers, which are the only things that produce hosts in bulk. It goes through the same
|
||||
/// repositories, the same outbox and the same automatic push as saving one — an import screen decides
|
||||
/// <em>which</em> hosts and <em>where</em> and nothing else, so there is no second way for a host or a
|
||||
/// 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>
|
||||
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
|
||||
@@ -8315,10 +8581,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal async Task<ImportOutcome> ImportHostsAsync(
|
||||
IReadOnlyList<ImportedHostRequest> requests,
|
||||
Guid? vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
|
||||
var target = vaultId ?? session.ActiveVaultId;
|
||||
var hosts = 0;
|
||||
var keys = 0;
|
||||
var refused = 0;
|
||||
@@ -8340,7 +8608,8 @@ internal sealed partial class VaultViewModel(
|
||||
continue;
|
||||
}
|
||||
|
||||
var (keyId, created) = await BindImportedKeyAsync(request, bound, cancellationToken)
|
||||
var (keyId, created) = await BindImportedKeyAsync(
|
||||
request, bound, target, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (created)
|
||||
@@ -8351,7 +8620,7 @@ internal sealed partial class VaultViewModel(
|
||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||
|
||||
await session.Hosts
|
||||
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
|
||||
.CreateAsync(target, host, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
hosts++;
|
||||
@@ -8381,6 +8650,7 @@ internal sealed partial class VaultViewModel(
|
||||
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
|
||||
ImportedHostRequest request,
|
||||
Dictionary<string, Guid> bound,
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Key is not { } key || request.KeyPath is not { } path)
|
||||
@@ -8407,7 +8677,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
var id = await session.SshKeys
|
||||
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
|
||||
.CreateAsync(vaultId, key, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
bound[path] = id;
|
||||
@@ -8482,6 +8752,12 @@ internal sealed partial class VaultViewModel(
|
||||
// destroys it, is not a pane anybody should have to read carefully.
|
||||
PendingDeletion = null;
|
||||
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;
|
||||
Status = string.Empty;
|
||||
}
|
||||
@@ -10967,7 +11243,52 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
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>
|
||||
@@ -11240,9 +11561,13 @@ internal sealed partial class VaultViewModel(
|
||||
HostAuthentication authentication,
|
||||
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
|
||||
{
|
||||
await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken)
|
||||
await ConnectAndAnnounceAsync(attempt, target, authentication, reporter, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
@@ -11268,10 +11593,87 @@ internal sealed partial class VaultViewModel(
|
||||
catch (Exception exception)
|
||||
{
|
||||
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>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -11337,6 +11739,7 @@ internal sealed partial class VaultViewModel(
|
||||
ConnectionAttemptEventArgs attempt,
|
||||
ConnectionTarget target,
|
||||
HostAuthentication authentication,
|
||||
PhaseReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Not reported before the await: the tab is constructed with this step already running — see
|
||||
@@ -11350,7 +11753,7 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Credential);
|
||||
|
||||
var sessionId = await workspace
|
||||
.OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
|
||||
.OpenSessionAsync(request, TerminalSize.Default, reporter, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||
|
||||
@@ -33,11 +33,14 @@ const SERVER_SESSION_ACTIVATED = 4;
|
||||
const SERVER_SESSION_REMOVED = 5;
|
||||
const SERVER_PASTE = 6;
|
||||
const SERVER_FONT_SIZE = 7;
|
||||
const SERVER_CLIPBOARD = 8;
|
||||
|
||||
const CLIENT_INPUT = 1;
|
||||
const CLIENT_ACKNOWLEDGE = 2;
|
||||
const CLIENT_RESIZE = 3;
|
||||
const CLIENT_FONT_SIZE_STEP = 4;
|
||||
const CLIENT_CLIPBOARD_COPY = 5;
|
||||
const CLIENT_CLIPBOARD_PASTE = 6;
|
||||
|
||||
const HEADER_LENGTH = 5;
|
||||
const SCROLLBACK_LINES = 5000;
|
||||
@@ -279,6 +282,66 @@ function handleKey(event) {
|
||||
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.
|
||||
*
|
||||
@@ -302,6 +365,19 @@ function createSession(sessionId) {
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
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,
|
||||
// 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.
|
||||
@@ -346,6 +422,10 @@ function createSession(sessionId) {
|
||||
|
||||
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) => {
|
||||
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
|
||||
});
|
||||
@@ -562,6 +642,35 @@ function handleFrame(buffer) {
|
||||
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: {
|
||||
if (payload.length < 1) {
|
||||
break;
|
||||
@@ -808,4 +917,15 @@ document.addEventListener('visibilitychange', () => {
|
||||
window.addEventListener('focus', 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();
|
||||
+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.
|
||||
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
|
||||
.NET tests, because the renderer they exercise is a `ClientWebSocket` standing in for this page.
|
||||
3. Confirm the right-click still belongs to `terminal.js`. Two things about xterm are load-bearing for it
|
||||
and neither is a global: `rightClickSelectsWord` has to still be an option — the page sets it false, and
|
||||
an option xterm no longer knows is silently ignored — and xterm has to still register its own right-click
|
||||
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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,23 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
||||
/// </remarks>
|
||||
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>
|
||||
/// Raised after a socket attaches — the first one, and every later takeover.
|
||||
/// </summary>
|
||||
@@ -463,16 +480,8 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// Answered before the session lookup, because it is the one frame that is not about a session. The
|
||||
// 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 (Announce(opcode, sessionId, payload))
|
||||
{
|
||||
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
|
||||
{
|
||||
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
|
||||
}
|
||||
|
||||
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>
|
||||
/// Computes the <c>Sec-WebSocket-Accept</c> value RFC 6455 §4.2.2 requires.
|
||||
/// </summary>
|
||||
|
||||
@@ -95,6 +95,31 @@ public enum TerminalServerOpcode : byte
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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>Frames the renderer sends to the host.</summary>
|
||||
@@ -131,6 +156,52 @@ public enum TerminalClientOpcode : byte
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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>
|
||||
|
||||
@@ -72,6 +72,28 @@ public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
|
||||
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>
|
||||
/// Owns the loopback data plane and every live terminal session.
|
||||
/// </summary>
|
||||
@@ -148,6 +170,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
// what remembers it between launches.
|
||||
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
|
||||
// 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
|
||||
@@ -313,6 +341,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// </remarks>
|
||||
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>
|
||||
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
|
||||
/// </summary>
|
||||
@@ -359,6 +405,55 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
TerminalFrame.CreateFontSizePayload(pixels)),
|
||||
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>
|
||||
/// Waits until the renderer page has attached its socket.
|
||||
/// </summary>
|
||||
|
||||
@@ -250,12 +250,19 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
/// way down rather than acted on as it bubbles.
|
||||
/// </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
|
||||
/// card is in play and every command on this screen is about the same host.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
|
||||
public async Task CtrlClickingCardsTicksThemAndTheLitCardWithoutMovingTheSelection()
|
||||
{
|
||||
await OnTheBoardAsync((screen, window) =>
|
||||
{
|
||||
@@ -267,16 +274,19 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
|
||||
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");
|
||||
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
|
||||
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise");
|
||||
|
||||
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);
|
||||
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
|
||||
vault.ChosenHostCount.ShouldBe(2);
|
||||
|
||||
Click(CardFor(screen, first), window);
|
||||
|
||||
@@ -387,6 +397,12 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
|
||||
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);
|
||||
|
||||
RightClick(tickedCard, window);
|
||||
|
||||
@@ -51,6 +51,18 @@ internal static class LayoutHarness
|
||||
/// </remarks>
|
||||
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>
|
||||
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
||||
internal const double NavRailWidth = 255;
|
||||
|
||||
@@ -210,32 +210,38 @@ public sealed class NavRailTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settings, Vaults and Preferences each enter settings mode on the page they promise, and shut the
|
||||
/// popover behind them.
|
||||
/// Settings enters settings mode on its landing page, and shuts the popover behind it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// v5c: these three used to land on a bare <c>ShellScreen</c> — Settings and Preferences on the very
|
||||
/// same one, since the mock's own Settings area did not exist yet. Now that it does, each opens the
|
||||
/// settings mode on its own page — see <see cref="MainWindowViewModel.EnterSettings"/> — and "Settings"
|
||||
/// and "Preferences" are no longer the same click.
|
||||
/// </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>
|
||||
/// v5c gave this menu three navigation rows — Settings, Vaults and Preferences — because settings mode
|
||||
/// had just been built out of what were two bare <c>ShellScreen</c>s, and "Settings" and "Preferences"
|
||||
/// had been the same click until then. All three opened the same mode and differed only in which page
|
||||
/// they landed on, which made two of them deep links into a place the third one opens, past a
|
||||
/// <c>SettingsNav</c> that lists every page anyway. One row now; see <c>NavRail.axaml</c>'s own remark.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task ThePopoversSettingsRow_EntersSettingsOnGeneralAndClosesThePopover() =>
|
||||
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]
|
||||
public Task ThePopoversVaultsRow_EntersSettingsOnVaultsAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Vaults", SettingsPage.Vaults);
|
||||
public Task ThePopoverNoLongerOffersVaultsOrPreferencesOfItsOwn() =>
|
||||
OnTheRailAsync((rail, window) =>
|
||||
{
|
||||
Click(UserChip(rail), window);
|
||||
|
||||
[Fact]
|
||||
public Task ThePopoversPreferencesRow_EntersSettingsOnPreferencesAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Preferences", SettingsPage.Preferences);
|
||||
PopoverRows(window).ShouldNotContain("Vaults");
|
||||
PopoverRows(window).ShouldNotContain("Preferences");
|
||||
PopoverRows(window).ShouldContain("Settings");
|
||||
});
|
||||
|
||||
private Task APopoverRowLandsOnAsync(string label, SettingsPage target) =>
|
||||
OnTheRailAsync((rail, window) =>
|
||||
@@ -369,6 +375,24 @@ public sealed class NavRailTests : IAsyncLifetime
|
||||
.OfType<TextBlock>()
|
||||
.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>
|
||||
private static bool HasVisibleCheck(Button row) =>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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 ----
|
||||
|
||||
[Fact]
|
||||
@@ -2043,6 +2086,40 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
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>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -416,6 +416,32 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
|
||||
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 ----
|
||||
|
||||
/// <remarks>
|
||||
@@ -538,4 +564,93 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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 />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
@@ -45,6 +45,31 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
/// </remarks>
|
||||
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 />
|
||||
public async Task<ISshConnection> ConnectAsync(
|
||||
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
|
||||
// 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.
|
||||
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)
|
||||
{
|
||||
@@ -419,3 +449,42 @@ internal sealed class FakeSshShellSession : ISshShellSession
|
||||
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
|
||||
/// rather than by remembering to click through them.
|
||||
/// </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";
|
||||
|
||||
/// <summary>Everything this shell has copied, newest last.</summary>
|
||||
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>
|
||||
/// 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.
|
||||
@@ -49,6 +63,15 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <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;
|
||||
},
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -194,7 +221,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
{
|
||||
save = onChanged;
|
||||
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;
|
||||
}
|
||||
@@ -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]
|
||||
public async Task AFreshMachine_AsksForAServer()
|
||||
{
|
||||
@@ -2414,6 +2530,84 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
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>
|
||||
/// 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,
|
||||
@@ -6383,6 +6577,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
Key: null,
|
||||
KeyPath: null),
|
||||
],
|
||||
vaultId: null,
|
||||
Token);
|
||||
|
||||
vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id");
|
||||
@@ -6920,6 +7115,60 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
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>
|
||||
/// 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
|
||||
@@ -7135,6 +7384,97 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
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>
|
||||
/// 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
|
||||
@@ -10010,6 +10350,33 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public async Task APassWhileOneIsAlreadyReady_DoesNotAskAgain()
|
||||
public async Task APassWhileOneIsAlreadyReady_AsksAgainAndDoesNotFetchTwice()
|
||||
{
|
||||
channel.Available = new AvailableUpdate("1.3.0");
|
||||
|
||||
@@ -400,7 +406,181 @@ public sealed class UpdateFlowTests : IDisposable
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<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,
|
||||
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>
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,36 @@
|
||||
|
||||
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
|
||||
reads at load. There is no Terminal, no fit addon and no WebGL here, because nothing on the reconnection
|
||||
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.
|
||||
reads at load.
|
||||
|
||||
◆ 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.
|
||||
|
||||
Both files are evaluated as scripts into the same global scope, and the page's own `const root` against
|
||||
a `var root` here is a SyntaxError before a line of either runs. That is why the page's two elements are
|
||||
Both files are evaluated as scripts into the same global scope, and a collision goes one of two ways.
|
||||
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
|
||||
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.
|
||||
|
||||
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. */
|
||||
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) {
|
||||
target.handlers = {};
|
||||
|
||||
@@ -41,11 +69,11 @@ function listeners(target) {
|
||||
target.handlers[name].push(handler);
|
||||
};
|
||||
|
||||
target.fire = function (name) {
|
||||
target.fire = function (name, event) {
|
||||
var handlers = target.handlers[name] || [];
|
||||
|
||||
for (var i = 0; i < handlers.length; i++) {
|
||||
handlers[i]({});
|
||||
handlers[i](event || {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -67,7 +95,22 @@ FakeSocket.OPEN = 1;
|
||||
FakeSocket.CLOSING = 2;
|
||||
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. */
|
||||
FakeSocket.prototype.close = function () {
|
||||
@@ -93,9 +136,31 @@ var pageRoot = listeners({
|
||||
var document = listeners({
|
||||
hidden: false,
|
||||
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({});
|
||||
|
||||
// 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 () {};
|
||||
}
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -165,6 +366,134 @@ function banner() {
|
||||
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. */
|
||||
function isClosed(index) {
|
||||
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>
|
||||
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>
|
||||
/// 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.
|
||||
|
||||
@@ -356,6 +356,82 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
||||
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]
|
||||
public async Task AFrameForAnUnregisteredSession_IsIgnored()
|
||||
{
|
||||
|
||||
@@ -268,6 +268,69 @@ public sealed class TerminalWorkspaceTests
|
||||
.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>
|
||||
/// 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
|
||||
|
||||
Reference in new issue
Block a user