Public Access
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ee2aacdc9 | ||
|
|
dec2cf164a | ||
|
|
7d1f0a4b53 | ||
|
|
fd92b80d4f | ||
|
|
dabc691c35 | ||
|
|
31c836f6ca | ||
|
|
b8c94ecb73 | ||
|
|
aff776cb42 | ||
|
|
91f7586b22 | ||
|
|
c859705b83 | ||
|
|
ed936380b3 | ||
|
|
c270f3fe39 | ||
|
|
56c19b006d | ||
|
|
1908189100 | ||
|
|
2810f7c12b | ||
|
|
c0dd5a147e | ||
|
|
c0396b4f4d | ||
|
|
06369274dc | ||
|
|
cfc2e3ae0c | ||
|
|
e2733e5cbc | ||
|
|
c2c860b4f2 | ||
|
|
4552538533 | ||
|
|
1bf79c7c26 | ||
|
|
1970e20e56 | ||
|
|
b2f93a172b | ||
|
|
882f01c515 | ||
|
|
2f8daacb60 | ||
|
|
ef12e8cc99 | ||
|
|
963cb7f670 | ||
|
|
68964d8a34 | ||
|
|
6934efb1e6 | ||
|
|
e252d337d1 | ||
|
|
fdea3911c1 | ||
|
|
10f80bded1 | ||
|
|
281f849086 | ||
|
|
25407756c3 | ||
|
|
a763f4b113 | ||
|
|
881562e81b | ||
|
|
93e35a0095 | ||
|
|
b80bf23341 | ||
|
|
8c58e5a558 | ||
|
|
cec73010d3 | ||
|
|
53ff15ba86 | ||
|
|
766fe6aebe | ||
|
|
9f73893e14 | ||
|
|
ccaf7a8e72 | ||
|
|
6fc1e3a7c5 | ||
|
|
8a77b7ca68 | ||
|
|
9755b5f6ae | ||
|
|
afc6a042f1 | ||
|
|
e41eca01a8 | ||
|
|
e96d01aab9 |
No files matched your search
+7
-1
@@ -20,8 +20,14 @@
|
||||
*.sql text
|
||||
|
||||
# Files that must keep LF regardless of platform — shell scripts run in Linux
|
||||
# containers, and Dockerfiles are read by a Linux daemon.
|
||||
# containers, Dockerfiles are read by a Linux daemon, and an RPM spec is a shell
|
||||
# script in disguise: rpmbuild feeds its %install section to /bin/sh, where a
|
||||
# carriage return at the end of a line is an argument rather than nothing. A udev
|
||||
# rule is parsed by udev's own lexer, which treats a stray carriage return as part
|
||||
# of the value it is reading.
|
||||
*.sh text eol=lf
|
||||
*.spec text eol=lf
|
||||
*.rules text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.Dockerfile text eol=lf
|
||||
Caddyfile text eol=lf
|
||||
|
||||
+174
-3
@@ -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
|
||||
@@ -462,6 +479,55 @@ jobs:
|
||||
|
||||
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
|
||||
|
||||
# ◆ AND THE THIRD DESKTOP PLATFORM, WHICH IS THE ONE THIS RUNNER ACTUALLY IS.
|
||||
#
|
||||
# Same argument as the two steps above and with none of their cross-compiling: this job runs on
|
||||
# Linux, so the linux-x64 publish here is a native one. What it proves that the others cannot is
|
||||
# the linux-x64 restore graph — libsodium, SkiaSharp and HarfBuzzSharp all ship native assets per
|
||||
# RID, and a package that resolves for win-x64 and has no linux-x64 asset fails here.
|
||||
#
|
||||
# The runner is still Alpine and the package targets glibc distributions, which is fine and is not
|
||||
# glossed over: scripts/ci-linux-rpm.sh explains why rpmbuild composing an RPM for one libc on a
|
||||
# host running another is correct rather than lucky.
|
||||
#
|
||||
# ◆ IT IS ALSO WHERE THE PACKAGING ITSELF IS PROVED, WHICH ON THIS PLATFORM IS NOT A FORMALITY.
|
||||
#
|
||||
# The Windows and macOS steps hand a directory to vpk and vpk decides what the package needs.
|
||||
# There is no vpk here — Velopack's Linux target is an AppImage and this is a distribution
|
||||
# package, updated by dnf rather than by the application — so the dependency metadata is
|
||||
# generated by rpm from the published tree, and scripts/ci-linux-rpm.sh interrogates the result
|
||||
# rather than trusting an exit code. Every check in there is a way a package can be built
|
||||
# successfully and then refuse to install on every host in the world; one of them was written
|
||||
# because .NET's own libmscordbi.so links libmscordaccore.so, which is exactly that.
|
||||
#
|
||||
# Release-shaped rather than nightly-shaped — package name dodossh, tree at /opt/DodoSSH — so
|
||||
# that what is proved on every main build is the packaging the release channel would use. The
|
||||
# desktop job below builds the nightly-shaped one and is the only job that publishes either.
|
||||
#
|
||||
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
|
||||
#
|
||||
# RUNNER_TEMP, dying with the job. An RPM is if anything the easiest of the three to pass on
|
||||
# casually — one file, no installer to click through, `dnf install` and done — which is a reason
|
||||
# to keep it off a downloads page rather than a reason to put it on one. ADR 0013 rule 3.
|
||||
- name: publish and package the linux desktop client
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# RestoreLockedMode=false for the RID, exactly as the two publishes above do — see the long
|
||||
# note on the win-x64 one. This checkout is thrown away, so the lock files it rewrites go
|
||||
# nowhere.
|
||||
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||
--configuration Release --runtime linux-x64 --self-contained true \
|
||||
-p:RestoreLockedMode=false \
|
||||
--output "$RUNNER_TEMP/linux-x64"
|
||||
|
||||
scripts/ci-linux-rpm.sh \
|
||||
--payload "$RUNNER_TEMP/linux-x64" \
|
||||
--version '${{ steps.winpack.outputs.packVersion }}' \
|
||||
--channel release \
|
||||
--out "$RUNNER_TEMP/linux-rpm"
|
||||
|
||||
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
||||
# server through Testcontainers and runs the API as a child process — so it needs a
|
||||
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
||||
@@ -507,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.
|
||||
@@ -866,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
|
||||
@@ -877,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.
|
||||
@@ -1048,6 +1121,45 @@ jobs:
|
||||
|
||||
ls -la "$RUNNER_TEMP/nightly-releases"
|
||||
|
||||
# ◆ AND THE LINUX NIGHTLY, WHICH IS A PACKAGE RATHER THAN A FEED.
|
||||
#
|
||||
# Everything above this step is Velopack: an installer, an index file and a client that polls it.
|
||||
# This one is an RPM, and the difference is not a gap in the work. VelopackUpdateChannel refuses
|
||||
# every platform that is not Windows or macOS — deliberately, and its comment says why — so a
|
||||
# Linux build has no self-updater to give a channel to. What it has instead is dnf, which is a
|
||||
# mechanism the user already trusts and already controls, and build/linux/dodossh.spec argues
|
||||
# that at length.
|
||||
#
|
||||
# So there is no rolling *feed* here, only a rolling *file*: the newest nightly RPM sits on the
|
||||
# same release as the Windows nightly and somebody who wants it downloads it. That is a worse
|
||||
# deal than the Windows nightly gets and it is the honest one — the alternative is a package
|
||||
# repository, which is a deployment this project does not have.
|
||||
#
|
||||
# DodoChannel=nightly for the same reason the win-x64 publish above sets it: it is what
|
||||
# ClientPaths reads to suffix the profile directory, so the nightly RPM and a release RPM
|
||||
# installed beside it share no cache, no outbox and no device key. The package name, the tree
|
||||
# under /opt and the desktop entry are the packaging half of that separation and are the
|
||||
# script's `--channel nightly` — see the note there for why it is four things and not one.
|
||||
- name: publish and package the linux nightly
|
||||
id: linuxpack
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||
--configuration Release --runtime linux-x64 --self-contained true \
|
||||
-p:RestoreLockedMode=false \
|
||||
-p:DodoChannel=nightly \
|
||||
-p:MinVerVersionOverride="$VERSION" \
|
||||
--output "$RUNNER_TEMP/nightly-linux-x64"
|
||||
|
||||
scripts/ci-linux-rpm.sh \
|
||||
--payload "$RUNNER_TEMP/nightly-linux-x64" \
|
||||
--version "$VERSION" \
|
||||
--channel nightly \
|
||||
--out "$RUNNER_TEMP/nightly-rpm"
|
||||
|
||||
# ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
|
||||
#
|
||||
# Whoever can write a release here can put a build on every nightly desktop, because Velopack fetches
|
||||
@@ -1138,17 +1250,76 @@ 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
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Published nightly desktop $VERSION as $TAG"
|
||||
# ◆ THE RPM GOES ONTO THE RELEASE vpk JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
|
||||
#
|
||||
# One rolling release carrying both heads, because they are one nightly: the same commit,
|
||||
# the same version, and one page a person looks at to find out what is on main. A second tag
|
||||
# would double the delete-and-recreate dance this step already does, and would let the two
|
||||
# drift apart on a run where one of them failed.
|
||||
#
|
||||
# It has to happen here, after `vpk upload`, and not in a step before it. vpk deletes and
|
||||
# recreates this release, so an RPM uploaded first would be attached to something that no
|
||||
# longer exists by the time the job ends — silently, because the delete succeeds.
|
||||
#
|
||||
# curl rather than vpk, unlike the assets above, for the reason the android job gives about
|
||||
# its APK: this is one file the release simply carries. There is no index to write and no
|
||||
# format that belongs to a tool.
|
||||
release="$(printf '%s' "$published" | release_id)"
|
||||
if [ -z "$release" ]; then
|
||||
echo "The release was published and its id could not be read back:" >&2
|
||||
printf '%s\n' "$published" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ◆ THE PACKAGE FIRST AND THE MANIFEST LAST, WHICH IS THE ORDER THE CLIENT DEPENDS ON.
|
||||
#
|
||||
# LinuxUpdateChannel reads linux-<channel>.json and then fetches what it names, exactly as the
|
||||
# phone reads android-nightly.json. A manifest published before its package is a window in
|
||||
# which every installed client is told to download something that is not there yet — and the
|
||||
# client's answer to a missing asset is to throw, which a pressed CHECK NOW reports as a
|
||||
# failure. Same rule, same reason, and the android job's comment says it first.
|
||||
rpm='${{ steps.linuxpack.outputs.rpm }}'
|
||||
manifest='${{ steps.linuxpack.outputs.manifest }}'
|
||||
|
||||
linuxNames=""
|
||||
for file in "$rpm" "$manifest"; do
|
||||
name="$(basename "$file")"
|
||||
echo "Uploading $name ($(wc -c < "$file") bytes)"
|
||||
curl -fsS -X POST -H "$auth" \
|
||||
-F "attachment=@$file" \
|
||||
"$api/releases/$release/assets?name=$name" >/dev/null
|
||||
linuxNames="$linuxNames $name"
|
||||
done
|
||||
|
||||
# Asked for back, exactly as the Velopack assets above are and for the same history: the
|
||||
# android job once created a release, uploaded nothing, and reported success for every
|
||||
# upload it never made.
|
||||
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||
for name in $linuxNames; do
|
||||
# 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
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Published nightly desktop $VERSION as $TAG, with$linuxNames"
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<Project>
|
||||
|
||||
<!--
|
||||
◆ THE TRIMMER'S VERSION IS PINNED HERE BECAUSE OTHERWISE THE LOCK FILES ARE NOT LOCKED.
|
||||
|
||||
Microsoft.NET.ILLink.Tasks is not referenced by anything in this repository. The SDK adds it
|
||||
on its own to any project that sets IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
|
||||
DodoSSH.Crypto do, and the Android head gets it from trimming being on by default there — and
|
||||
the version it asks for is whatever the running SDK happens to bundle. That version lives in
|
||||
the SDK's own Microsoft.NETCoreSdk.BundledVersions.props, as a KnownILLinkPack item.
|
||||
|
||||
Which makes it a dependency whose version is a property of the toolchain rather than of this
|
||||
repository, and that is the whole problem: packages.lock.json records it as a Direct reference
|
||||
with a requested range, so the lock file silently means "whichever SDK last ran a restore".
|
||||
global.json says rollForward: latestMinor, so CI's setup-dotnet installs the newest 10.x SDK
|
||||
that exists on the day it runs. The moment .NET ships a servicing release, CI's SDK asks for a
|
||||
version the committed lock files do not have, and the locked-mode restore in ci.yml fails with
|
||||
NU1004 before a single file is compiled.
|
||||
|
||||
That is not hypothetical. It closed the whole pipeline: main's run 125 and every open pull
|
||||
request went red together, on
|
||||
|
||||
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||
from [10.0.10, ) to [10.0.11, ).
|
||||
|
||||
with nothing in any of those commits touching a package. .NET had shipped SDK 10.0.400, which
|
||||
bundles ILLink 10.0.11 where 10.0.302 bundled 10.0.10, and setup-dotnet installed it the next
|
||||
time anything ran.
|
||||
|
||||
Worse than the outage is the shape of the repair without this pin. Regenerating the lock files
|
||||
holds only until the next servicing release, and it cannot be done from a machine whose newest
|
||||
SDK is older than the runner's: a restore on 10.0.302 writes 10.0.10 straight back and re-breaks
|
||||
CI, so the recorded version becomes a fact about whoever ran restore last rather than about this
|
||||
repository. That is exactly the state locking exists to prevent, and it is not a hypothetical
|
||||
either — every SDK installed on the machine this pin was written on tops out at 10.0.302.
|
||||
|
||||
Pinning it makes the recorded version a decision this repository made, reviewable in a diff
|
||||
like every other version in Directory.Packages.props, and identical on every machine whatever
|
||||
SDK it has. Moving it is then a deliberate edit here plus a regenerated lock file, which is the
|
||||
same ceremony any other dependency bump gets.
|
||||
|
||||
It is an Update on the SDK's item rather than a PackageVersion in Directory.Packages.props, and
|
||||
it has to be: the reference is implicit, so the SDK supplies the version itself and central
|
||||
package management never gets asked. ProcessFrameworkReferences reads @(KnownILLinkPack) when
|
||||
it runs, which is why this lives in Directory.Build.targets — the item does not exist yet while
|
||||
Directory.Build.props is being evaluated.
|
||||
|
||||
Keep this within a patch or two of the runtime the SDK ships. It is the trimming analyzer and
|
||||
the ILLink task, so a small skew is harmless, but a version far behind the framework being
|
||||
analysed is a real way to miss a trim warning.
|
||||
-->
|
||||
<Target Name="PinTheILLinkPackVersion" BeforeTargets="ProcessFrameworkReferences">
|
||||
<!--
|
||||
Inside a target, and not for tidiness. The SDK ships one KnownILLinkPack per target framework
|
||||
and they all share the identity "Microsoft.NET.ILLink.Tasks", so the TargetFramework metadata
|
||||
is the only thing telling net10.0's entry from net8.0's. A condition on %(...) is item
|
||||
batching, which MSBuild permits in a target and rejects during evaluation with MSB4191 — so
|
||||
an ItemGroup at the top of this file cannot express "only the net10.0 one" at all, and the
|
||||
unconditioned Update it would have to become rewrites every framework's entry.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<KnownILLinkPack Update="Microsoft.NET.ILLink.Tasks"
|
||||
Condition="'%(TargetFramework)' == 'net10.0'"
|
||||
ILLinkPackVersion="10.0.11" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -88,8 +88,12 @@
|
||||
-->
|
||||
<PackageVersion Include="NSec.Cryptography" Version="26.4.0" />
|
||||
<PackageVersion Include="libsodium" Version="1.0.22" />
|
||||
<!-- Managed differential oracle for the crypto test suite only. -->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<!--
|
||||
Managed differential oracle for the crypto test suite only. Also what SSH.NET pulls in,
|
||||
so central transitive pinning makes this pin its floor too: 2.7.0 is what SSH.NET
|
||||
2026.0.0 asks for, and pinning below that is a downgrade error rather than a preference.
|
||||
-->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Desktop client">
|
||||
<!--
|
||||
@@ -98,8 +102,14 @@
|
||||
agent forwarding (needs an upstream change; de-scoped from v1) and being handed a
|
||||
pre-connected Stream — it performs its own socket connect, which is why the relay and
|
||||
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
|
||||
|
||||
2026.0.0 is the fix for GHSA-q939-rpr3-3284, a path traversal in ScpClient.Download's
|
||||
recursive mode that trusts server-supplied names (everything up to and including
|
||||
2025.1.0 is affected). Nothing here uses ScpClient — transfers go through SftpClient —
|
||||
so this was an upgrade on principle rather than an exposure, and the release notes
|
||||
list no breaking changes over 2025.1.0.
|
||||
-->
|
||||
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
|
||||
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
|
||||
<!--
|
||||
The S3 client, for buckets as a remote in the file browser. First-party, Apache-2.0, and
|
||||
managed only — no native assets — which is the bar this file sets for anything that gets
|
||||
@@ -163,6 +173,28 @@
|
||||
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
|
||||
-->
|
||||
<PackageVersion Include="Velopack" Version="1.2.0" />
|
||||
|
||||
<!--
|
||||
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
|
||||
|
||||
Windows needs nothing here: CNG is in the framework and the Platform Crypto Provider is the TPM
|
||||
behind it, and macOS reaches the Secure Enclave through the Security framework that
|
||||
MacSymbols.cs binds by hand. Linux has neither — the platform's own stack is tpm2-tss, a C
|
||||
library with a large surface — so this is the one head that needs a package to talk to its own
|
||||
hardware.
|
||||
|
||||
Chosen over P/Invoking libtss2-esys for two reasons that both hold at the same time. It is
|
||||
managed all the way down, so there is no native dependency to add to the RPM and nothing to
|
||||
resolve at load time; and it works against a software TPM over a socket exactly as it does
|
||||
against /dev/tpmrm0, which is what makes LinuxDeviceKeyStoreTests able to run the whole seal,
|
||||
unseal and lockout path unattended. Neither of the other two device key stores can be tested at
|
||||
all — see WindowsDeviceKeyStoreTests for the ten minutes that took to find out.
|
||||
|
||||
It carries a runtimes/linux-x64 asset, like SkiaSharp and libsodium, so the RID-specific publish
|
||||
picks it up and the RID-free locked restore does not. See the RuntimeIdentifiers note in
|
||||
DodoSSH.Client.App.csproj for why that distinction is load-bearing here.
|
||||
-->
|
||||
<PackageVersion Include="Microsoft.TSS" Version="2.1.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Versioning">
|
||||
<!--
|
||||
@@ -211,5 +243,20 @@
|
||||
mistakes that cause real authorization holes.
|
||||
-->
|
||||
<PackageVersion Include="WireMock.Net" Version="2.13.0" />
|
||||
<!--
|
||||
A JavaScript interpreter, in managed code, so that terminal.js can be tested as the file that
|
||||
ships rather than as a transcription of it into C#. Test-only and referenced by exactly one
|
||||
project; nothing in src depends on it.
|
||||
|
||||
Chosen over shelling out to node, which is the obvious alternative and needs node present
|
||||
wherever the suite runs — CI installs one already, but a test that only runs when a separate
|
||||
command is remembered is a test that stops being run. Chosen over a headless browser for the
|
||||
same reason several times over.
|
||||
|
||||
What it does not buy: Jint is not Chromium, so this proves the page's own logic and nothing
|
||||
about how WebView2 or Android's WebView behave. That line is drawn in RendererPage's remark and
|
||||
picked up by docs/manual-checks.md 1.10 and 11.12a.
|
||||
-->
|
||||
<PackageVersion Include="Jint" Version="4.16.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -69,9 +69,10 @@ 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 integration
|
||||
DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
|
||||
DodoSSH.Client.Android the phone head: its views, and its Android integration
|
||||
tests/ one test project per source project
|
||||
docs/adr/ architecture decision records
|
||||
@@ -81,7 +82,9 @@ docs/manual-checks.md what no test can reach, and what to look for when che
|
||||
docs/android-port.md the Android head: what was decided, what is built, what is left
|
||||
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
|
||||
release-macos.sh — the same, signed and notarized, on a Mac
|
||||
ci-linux-rpm.sh — builds the Linux .rpm, then interrogates it
|
||||
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
|
||||
build/linux/ the RPM spec the Linux package is built from
|
||||
```
|
||||
|
||||
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
||||
@@ -177,6 +180,74 @@ passphrase screen, always. The application lives at `/Applications/DodoSSH.Deskt
|
||||
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
|
||||
first never touches the second.
|
||||
|
||||
## Installing on Linux
|
||||
|
||||
An `.rpm` for x86_64, and the one platform where you get it from `main` rather than from a release — see
|
||||
[the nightly build](#the-nightly-desktop-build) below, which is where the Linux package is published today.
|
||||
|
||||
```bash
|
||||
sudo dnf install ./dodossh-nightly-*.x86_64.rpm
|
||||
```
|
||||
|
||||
Everything above about where a client may come from applies unchanged. Four things differ, and the first
|
||||
two are the ones to read before installing.
|
||||
|
||||
**It checks for updates and it installs nothing.** The Windows and macOS builds carry Velopack and replace
|
||||
their own files; this one never does. It checks the project's forge every six hours, and when there is a
|
||||
newer build it downloads it, checks its digest, and hands the file to **PackageKit** — which raises the
|
||||
usual "authentication is required to install software" prompt. You may say no, and nothing is replaced until
|
||||
you say yes. Nothing restarts either: replacing files under a running process is ordinary here, so the build
|
||||
you have keeps running until you close it.
|
||||
|
||||
That split is the point rather than a compromise. Installing software is your package manager's job and it
|
||||
already answers to you; noticing that a build exists is the part `dnf` cannot do for a package that is not
|
||||
in a repository. What DodoSSH must never become is a second thing that can put software on your machine on
|
||||
somebody else's say-so, which is why the address it checks is a constant in the binary and can never be a
|
||||
DodoSSH server — [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) rules 4 and 11.
|
||||
|
||||
The gap that remains: there is **no package repository**, so `dnf upgrade` alone will never bring you a
|
||||
newer DodoSSH. If you turn the check off on PREFERENCES → UPDATES, nothing else will tell you.
|
||||
|
||||
**Your PIN can stand in for your passphrase, if this machine has a TPM you can reach.** The device key is
|
||||
sealed into the TPM and released only against a PIN that **the TPM itself checks** — it counts wrong answers
|
||||
and stops responding after a few, which is what makes four digits enough and what makes a stolen disk
|
||||
useless. Two honest caveats, both in
|
||||
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4:
|
||||
|
||||
- **It is weaker than Windows Hello or Touch ID**, and the difference does not show. Those draw their prompt
|
||||
from inside the operating system; this one is a window DodoSSH draws, so something already running as you
|
||||
could imitate it. It could imitate the passphrase screen too, which is worth far more — but the guarantee
|
||||
is not the same and this page will not pretend it is.
|
||||
- **The package grants your session access to the TPM, and you should know what that means.** Fedora leaves
|
||||
`/dev/tpmrm0` readable only by the `tss` group, so this would otherwise need an administrator and a fresh
|
||||
login. Instead the package installs a udev rule tagging the device `uaccess` — the same mechanism your
|
||||
webcam and your FIDO key already use, which grants an ACL to **whoever is logged in at the machine** and
|
||||
takes it away when they log out. Not over SSH, not to a user switched away on another VT, not to daemons.
|
||||
|
||||
The cost is not secrecy — TPM objects are protected by their own PINs — it is that anyone at the keyboard
|
||||
can make failed attempts, and a TPM answers enough of those by locking out *everyone*. **If this machine
|
||||
unlocks its disk with the TPM at boot**, that means somebody sitting at it could stop the next boot from
|
||||
unlocking until the lockout recovers. If you would rather not have that, one command puts it back:
|
||||
|
||||
```bash
|
||||
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
|
||||
```
|
||||
|
||||
DodoSSH then behaves as though the machine had no TPM: no PIN offered, passphrase every time.
|
||||
|
||||
See [docs/unlocking-without-the-passphrase.md](docs/unlocking-without-the-passphrase.md).
|
||||
|
||||
**It needs WebKitGTK, because the terminal is a WebView.** Specifically `webkit2gtk4.1`, which `dnf` pulls
|
||||
in for you. Fedora 44 is the distribution the head has actually been run on. RHEL 9 and its rebuilds ship
|
||||
the older 4.0 API line under a different library name and the package deliberately does not claim them —
|
||||
`dnf` will refuse with *nothing provides libwebkit2gtk-4.1.so.0*, which is a better answer than installing
|
||||
and drawing a blank terminal. This project does not ship desktop builds nobody has run, on any platform.
|
||||
|
||||
**Where things go.** The application at `/opt/DodoSSH`, launched as `dodossh` or from your menu; your vault
|
||||
cache at `$XDG_DATA_HOME/dodossh`, usually `~/.local/share/dodossh`. Two different places on purpose:
|
||||
`dnf remove` takes the first and never touches the second, so reinstalling asks for your passphrase rather
|
||||
than starting over. Use **Sign out** inside the application to make the machine genuinely forget.
|
||||
|
||||
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
|
||||
`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
|
||||
not only that the runners are Linux.
|
||||
@@ -189,6 +260,18 @@ Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\Dodo
|
||||
costs nothing you are relying on. It signs in separately and appears to your account as a new device,
|
||||
because it genuinely is a second installation.
|
||||
|
||||
**The Linux `.rpm` is on the same release**, built from the same commit and versioned the same way, as
|
||||
`dodossh-nightly`. It installs beside a release package for the same reasons and by the same four
|
||||
separations — a different package name, a tree at `/opt/DodoSSH.Nightly`, a `dodossh-nightly` launcher with
|
||||
its own menu entry, and a profile at `~/.local/share/dodossh.nightly`. Two RPMs owning different paths
|
||||
cannot replace one another, so `dnf` will keep both rather than treating one as an upgrade of the other.
|
||||
|
||||
It is the only Linux package this project publishes at all today: there is no `v*`-tagged Linux release
|
||||
yet, because no person has walked a Linux build through
|
||||
[docs/manual-checks.md](docs/manual-checks.md). What CI proves on every push to `main` is that the release
|
||||
package still *builds* — the same arrangement Windows and macOS have had since the beginning, where the
|
||||
package is built on every commit and uploaded nowhere.
|
||||
|
||||
**Installing it means trusting everyone who can write a release on this repository, including CI.** The
|
||||
updater applies what the feed serves without verifying a signature, so anyone who can change a workflow
|
||||
file here can put a build on every nightly machine. That is an acceptable trade for a build you are trying
|
||||
@@ -920,10 +1003,31 @@ keychain plus a terminal — and the spike that gates all of it.
|
||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
|
||||
[Installing on macOS](#installing-on-macos).
|
||||
|
||||
**The Linux half checks, and the package manager installs.** An `.rpm` for x86_64, built and checked on
|
||||
every push to `main` and published to the nightly release, installing to `/opt` with a menu entry and a
|
||||
`dodossh` launcher. It carries no Velopack and replaces nothing itself: it notices a newer build on the
|
||||
project's forge, downloads it, verifies its digest and hands the file to PackageKit, which asks before it
|
||||
installs. Splitting it that way keeps the part that can put software on your machine where it already
|
||||
was — with your package manager — while fixing the half `dnf` cannot do for a package that is in no
|
||||
repository. The device key is sealed into the TPM behind a PIN **the TPM checks and counts failures
|
||||
against**, which is what makes four digits enough. Reaching the TPM needs no administrator and no group:
|
||||
the package installs a udev rule tagging `/dev/tpmrm0` with `uaccess`, so the ACL follows whoever is
|
||||
logged in at the machine and goes away with them — the same mechanism a webcam uses. What that costs, and
|
||||
the one symlink that undoes it, is in the rule's own comments. See
|
||||
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4,
|
||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 11 and
|
||||
[Installing on Linux](#installing-on-linux).
|
||||
|
||||
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
|
||||
certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the
|
||||
manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
|
||||
everything above is verified only as far as the bundle.
|
||||
certificate is the first release aimed at strangers), macOS on Intel, a `v*`-tagged Linux release and a
|
||||
signed package repository to serve it from, and Phases 18 and 19 of the manual checks — neither the macOS
|
||||
nor the Linux build has been walked through by a person. There is no macOS runner in CI at all. The Linux
|
||||
package is verified as far as what it *says* about itself — CI builds it and interrogates its
|
||||
dependencies, its file list and its modes, and installs nothing — and the two pieces of Linux behaviour
|
||||
that are code rather than packaging do have tests: the update channel's version comparison and digest
|
||||
check run in CI, and the TPM store's seal, unseal, wrong-PIN and lockout paths run against a software TPM
|
||||
anybody can start with one `docker run`. What no runner covers is `/dev/tpmrm0`, PackageKit and polkit,
|
||||
which is what Phase 19 is for.
|
||||
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
||||
|
||||
## Licence
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Lets the person sitting at this machine use its TPM, without joining a group.
|
||||
#
|
||||
# ◆ WHAT THIS CHANGES, AND WHAT IT DELIBERATELY DOES NOT.
|
||||
#
|
||||
# Fedora's 60-tpm-udev.rules leaves /dev/tpmrm0 as root:tss mode 0660, so using it means being in
|
||||
# the tss group — which means an administrator, a usermod and a fresh login before DodoSSH will
|
||||
# offer to seal a device key. This adds the uaccess tag, and nothing else: the mode and the group
|
||||
# are untouched, so everything that worked before still works and an ACL is layered on top.
|
||||
#
|
||||
# uaccess is systemd's own mechanism for exactly this question and is what /dev/dri, webcams,
|
||||
# scanners and FIDO keys already use. logind grants a POSIX ACL to the user of the *active local
|
||||
# session* and takes it away again when that session ends. So:
|
||||
#
|
||||
# - somebody logged in at the physical machine gets access;
|
||||
# - somebody logged in over SSH does not;
|
||||
# - a user switched away to another VT does not;
|
||||
# - a system daemon running as its own user does not.
|
||||
#
|
||||
# That is a much narrower grant than adding a person to tss, which is permanent, applies over SSH,
|
||||
# and applies whether or not they are the one at the keyboard.
|
||||
#
|
||||
# ◆ tpmrm AND NOT tpm, WHICH IS THE WHOLE REASON THIS IS DEFENSIBLE.
|
||||
#
|
||||
# /dev/tpm0 is the raw device and access to it is exclusive — two users cannot share it, and a
|
||||
# process holding it can leave the chip in a state that breaks everything else. /dev/tpmrm0 is the
|
||||
# kernel's resource manager: it virtualises handles, swaps contexts and isolates callers from each
|
||||
# other, and it exists *because* the TPM is meant to be usable by more than one unprivileged
|
||||
# program. Granting a seat's user the manager is the use it was built for. Granting the raw device
|
||||
# would not be, and this rule never names it.
|
||||
#
|
||||
# ◆ THE COST, WHICH IS REAL AND IS NOT ABOUT SECRECY.
|
||||
#
|
||||
# TPM keys are protected by their own auth values, so this grants nobody the ability to read
|
||||
# anybody else's sealed data. What it does grant is the ability to make failed authorisation
|
||||
# attempts, and a TPM answers those by counting them and eventually refusing everyone — the same
|
||||
# dictionary-attack lockout DodoSSH relies on to make a four-digit PIN safe.
|
||||
#
|
||||
# On a machine whose disk is unlocked by the TPM at boot (systemd-cryptenroll, Clevis), a local user
|
||||
# who trips that lockout can stop the next boot from unlocking until it recovers. That is a denial
|
||||
# of service against the machine's owner by somebody already sitting at the machine, and it is worth
|
||||
# knowing rather than discovering. An administrator who does not accept it can mask this file:
|
||||
#
|
||||
# sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
|
||||
#
|
||||
# which is the standard override and survives upgrades. DodoSSH then behaves exactly as it did
|
||||
# before this file existed: no device key is offered, and unlocking asks for the passphrase.
|
||||
#
|
||||
# Numbered 71 because 73-seat-late.rules is what turns the tag into an ACL, and 60-tpm-udev.rules
|
||||
# is what creates the device node's ownership in the first place. Anything outside that gap either
|
||||
# never runs or runs too late to matter.
|
||||
|
||||
ACTION=="remove", GOTO="dodossh_tpm_end"
|
||||
SUBSYSTEM=="tpmrm", KERNEL=="tpmrm[0-9]*", TAG+="uaccess"
|
||||
LABEL="dodossh_tpm_end"
|
||||
@@ -0,0 +1,342 @@
|
||||
# ==================================================================================================
|
||||
# The Linux desktop head, as an RPM.
|
||||
#
|
||||
# Built by scripts/ci-linux-rpm.sh, which is what supplies every dodo_* macro below and what checks
|
||||
# the result. Nothing here is invoked by hand; `rpmbuild -bb` on this file with no --define set will
|
||||
# fail on the Version line, which is the intended way round.
|
||||
#
|
||||
# ◆ WHY THIS IS AN RPM AND NOT A VELOPACK RELEASE, WHICH IS THE FIRST QUESTION.
|
||||
#
|
||||
# Both desktop platforms that ship today are Velopack: the client polls the project's forge, fetches
|
||||
# a newer build and swaps itself over. ADR 0013 rule 4 is what makes that safe to hold — the feed
|
||||
# address is a const, so no operator can answer "is there a newer version" on the user's behalf.
|
||||
#
|
||||
# This package replaces nothing and installs nothing, and that is deliberate rather than unfinished.
|
||||
# On Linux the package manager is already an updater the user trusts, audits and controls, so putting
|
||||
# a second one inside the application — answering to this project instead — would be building the
|
||||
# capability ADR 0013 rule 4 spends a page making unsettable, on the one platform where nobody had to.
|
||||
#
|
||||
# What the client does do is the half a package manager cannot: LinuxUpdateChannel notices that a
|
||||
# newer build exists on the project's forge, fetches it, checks its digest, and hands the file to
|
||||
# PackageKit — which raises polkit and asks. The user may say no, and nothing is replaced until they
|
||||
# say yes. See ADR 0013 rule 11.
|
||||
#
|
||||
# The consequence to know: discovery is worse here than on the other two desktops, because there is
|
||||
# no package repository to subscribe to and the in-app check is what stands in for one.
|
||||
# ==================================================================================================
|
||||
|
||||
# ◆ THE THREE THINGS THAT MUST BE TURNED OFF, AND WHAT EACH ONE WOULD DO.
|
||||
#
|
||||
# __os_install_post is the chain of brp-* scripts rpm runs over the buildroot: strip, strip-static,
|
||||
# compress, elfperms. Every one of them is wrong here. The payload is a published .NET tree whose
|
||||
# managed PDBs ADR 0013 rule 6 keeps on purpose — a stack with file names and line numbers is this
|
||||
# project's entire diagnostic channel — and brp-strip would take the native symbols with them for a
|
||||
# saving nobody asked for. It also runs the host's binutils over glibc binaries on a build host that
|
||||
# is musl, which is a class of breakage with no good failure message.
|
||||
#
|
||||
# debug_package would then try to build a -debuginfo subpackage out of what strip extracted, from
|
||||
# third-party binaries nobody here will ever step through.
|
||||
#
|
||||
# _build_id_links is the one that surprises people, because turning the other two off does not turn
|
||||
# it off: rpm still writes /usr/lib/.build-id/<xx>/<hash> symlinks for every ELF file in the payload.
|
||||
# For a bundled tree that means dozens of links in a system directory this package has no business
|
||||
# owning, and two files with the same build id — which a .NET publish does produce — make it a build
|
||||
# *warning* about duplicates rather than an error, so it goes unnoticed.
|
||||
%global __os_install_post %{nil}
|
||||
%global debug_package %{nil}
|
||||
%global _build_id_links none
|
||||
|
||||
# ◆ AUTO-REQUIRES STAYS ON. AUTO-PROVIDES DOES NOT. THE ASYMMETRY IS THE WHOLE POINT.
|
||||
#
|
||||
# rpm's elfdeps reads DT_NEEDED and the symbol-version table out of every ELF file in the buildroot,
|
||||
# and the requires half of that is worth much more than a hand-written list: it produces
|
||||
# `libc.so.6(GLIBC_2.38)(64bit)` rather than `glibc`, so the package refuses to install on a host
|
||||
# whose glibc is too old instead of installing and dying at the first launch. A hand list cannot
|
||||
# express that and would go stale the first time a dependency changed underneath it.
|
||||
#
|
||||
# It reads ELF directly rather than by loading anything, so it does this correctly for x86_64 glibc
|
||||
# binaries on a musl build host — which is what lets this package be built on the same Alpine runner
|
||||
# as everything else in ci.yml.
|
||||
#
|
||||
# The provides half is the opposite. Everything under /opt here is a *bundled* copy: libSkiaSharp,
|
||||
# libcoreclr, libe_sqlite3 and the rest belong to this application and to nothing else. Left on,
|
||||
# this package would advertise `libcoreclr.so()(64bit)` to the whole system, and some other package's
|
||||
# dependency could then be satisfied by a private copy inside a directory `dnf remove dodossh` takes
|
||||
# away. Excluding the tree by path is the standard shape for that and costs nothing, because nothing
|
||||
# outside this package is meant to link against anything inside it.
|
||||
#
|
||||
# ◆ WHICH IS WHY THE REQUIRES SIDE NEEDS AN EXCLUSION TOO, AND WHY IT IS COMPUTED.
|
||||
#
|
||||
# The two halves are not independent. A bundled library with a DT_NEEDED on another bundled library
|
||||
# produces a requirement for a soname this package carries and — after the exclusion above — no
|
||||
# longer advertises, which no host on earth can satisfy. It is not hypothetical and it is not rare:
|
||||
# .NET's own libmscordbi.so links libmscordaccore.so, so the very first realistic payload this was
|
||||
# tried against produced a package `dnf install` refuses outright.
|
||||
#
|
||||
# dodo_bundled is the alternation of every lib*.so* filename in the payload, worked out by
|
||||
# scripts/ci-linux-rpm.sh from the tree it was handed rather than listed here. A list in this file
|
||||
# would be a list of Microsoft's and SkiaSharp's internal file names, kept in step by hand, and its
|
||||
# failure mode is the uninstallable package above.
|
||||
#
|
||||
# liblttng-ust is excluded on its own terms and not because it is bundled — it is not. It is what
|
||||
# libcoreclrtraceptprovider.so links, and that file exists so that CoreCLR can emit LTTng trace
|
||||
# events if a host has the tracing stack. Nothing here asks it to. The file is kept and the
|
||||
# requirement is dropped, which is the arrangement that works both ways round: where liblttng-ust is
|
||||
# present the dlopen succeeds and tracing is available, and where it is absent CoreCLR carries on, as
|
||||
# it does on every machine that runs a .NET application without it. Requiring it instead would put
|
||||
# the package out of reach on RHEL, where lttng-ust is in EPEL rather than in the base repositories —
|
||||
# an install failure on a supported distribution, in exchange for a tracing integration this
|
||||
# application does not use.
|
||||
#
|
||||
# scripts/ci-linux-rpm.sh checks the finished package against its own file list afterwards, because
|
||||
# an exclusion that silently stopped matching would put the uninstallable package back with no
|
||||
# warning anywhere.
|
||||
%global __provides_exclude_from ^/opt/.*$
|
||||
%global __requires_exclude ^(liblttng-ust\.so|%{dodo_bundled})
|
||||
|
||||
Name: %{dodo_name}
|
||||
Version: %{dodo_version}
|
||||
Release: %{dodo_release}
|
||||
Summary: SSH client with an end-to-end encrypted credential vault
|
||||
License: MIT
|
||||
URL: https://git.dodotech.cloud/DodoTech-Public/DodoSSH
|
||||
BuildArch: x86_64
|
||||
|
||||
# ◆ THE DEPENDENCIES elfdeps CANNOT SEE, WHICH ARE THE ONES THE PRODUCT IS MADE OF.
|
||||
#
|
||||
# Everything below is reached through dlopen, by a name composed at runtime, so it appears in no
|
||||
# DT_NEEDED entry anywhere in the payload. Left to the automatic scan the package would install
|
||||
# cleanly on a host with none of it and fail at the first window.
|
||||
#
|
||||
# GTK and WebKitGTK are the terminal. Avalonia.Controls.WebView P/Invokes a bare `libwebkit2gtk` and
|
||||
# a bare `libgtk` and resolves them itself, and MainWindow.OnTerminalEnvironmentRequested documents
|
||||
# the backend it was measured against: WebKitGTK 2.52.5 on Fedora 44, which is the 4.1 API line — the
|
||||
# assembly's one fully-spelled soname, libjavascriptcoregtk-4.1.so.0, is that line's other half.
|
||||
#
|
||||
# ◆ THE 4.0 LINE IS DELIBERATELY NOT CLAIMED, AND THE COST OF THAT IS KNOWN.
|
||||
#
|
||||
# The control's own diagnostic says "webkit2gtk 4.0+", so a dependency permitting either line is
|
||||
# tempting and was written first. RHEL 9 and its rebuilds are what it would buy: they ship
|
||||
# webkit2gtk3, which is the 4.0 API under the soname libwebkit2gtk-4.0.so.37 — not .so.0, which is
|
||||
# what makes this a thing to look up rather than guess. Requiring only 4.1 means `dnf install` on
|
||||
# AlmaLinux 9 refuses with "nothing provides libwebkit2gtk-4.1.so.0", by design.
|
||||
#
|
||||
# It is refused because nobody has run it. This project does not ship an Intel macOS package for the
|
||||
# same reason and says so in ADR 0013 decision 10 — the engineering is small and the verification is
|
||||
# the scarce thing — and an install that succeeds and then draws a blank terminal is a worse answer
|
||||
# than one that refuses in a sentence naming the missing library. The day somebody runs the head on
|
||||
# a 4.0 host, this becomes a rich `or` and a line in the README.
|
||||
#
|
||||
# libX11 is Avalonia's own backend, P/Invoked the same way. On Wayland this is satisfied by XWayland,
|
||||
# which every desktop shipping this package has.
|
||||
#
|
||||
# libicu is the one that looks removable and is not. DodoSSH.Client.App sets InvariantGlobalization
|
||||
# to false — deliberately, and the csproj says why: this process prints timestamps and host names to
|
||||
# a person who expects their own locale's conventions. A .NET process configured that way and given
|
||||
# no ICU does not degrade to invariant, it refuses to start. The bare package name is the Fedora and
|
||||
# RHEL spelling; it is the one name here that is a distribution's rather than a soname's, because ICU
|
||||
# bumps its soname on every release and pinning one would make this package expire.
|
||||
Requires: libwebkit2gtk-4.1.so.0()(64bit)
|
||||
Requires: libgtk-3.so.0()(64bit)
|
||||
Requires: libgdk-3.so.0()(64bit)
|
||||
Requires: libgio-2.0.so.0()(64bit)
|
||||
Requires: libglib-2.0.so.0()(64bit)
|
||||
Requires: libgobject-2.0.so.0()(64bit)
|
||||
Requires: libX11.so.6()(64bit)
|
||||
Requires: libicu
|
||||
|
||||
# The size directories under /usr/share/icons/hicolor belong to hicolor-icon-theme and not to this
|
||||
# package, which is the right way round — every application drops icons into them. Depending on the
|
||||
# owner is how those directories come to exist at all on a minimal host, and how they get removed
|
||||
# again when the last thing using them goes.
|
||||
Requires: hicolor-icon-theme
|
||||
|
||||
# ◆ RECOMMENDED RATHER THAN REQUIRED, WHICH IS A JUDGEMENT ABOUT WHAT THE PACKAGE IS FOR.
|
||||
#
|
||||
# PackageKit is what LinuxUpdateChannel hands a downloaded update to, so without it the update path
|
||||
# stops at the last step. It is not required, for two reasons that both point the same way: dnf
|
||||
# installs weak dependencies by default, so the ordinary Fedora install gets it anyway; and a machine
|
||||
# deliberately built without it — a minimal or an immutable one — should still be able to install an
|
||||
# SSH client. The failure is not silent, which is what makes this safe: the channel names the missing
|
||||
# service and the file it fetched, so the answer is one command the user was going to run anyway.
|
||||
#
|
||||
# Nothing is required for the TPM. LinuxDeviceKeyStore talks to /dev/tpmrm0 through a managed stack,
|
||||
# so there is no library to depend on, and a machine with no TPM — or one whose device node this user
|
||||
# may not open, which is the ordinary Fedora case — simply keeps asking for the passphrase.
|
||||
Recommends: PackageKit
|
||||
|
||||
%description
|
||||
DodoSSH is an SSH and SFTP client for teams whose credentials are shared and whose infrastructure is
|
||||
not. Hosts, keys and passphrases live in a vault that is encrypted on this machine and stays
|
||||
encrypted on the server, so the deployment holding a team's secrets can never read them.
|
||||
|
||||
This package carries its own .NET runtime. It checks the project's own forge for newer builds and can
|
||||
download one, but it installs nothing itself: an update is handed to PackageKit, which asks before it
|
||||
replaces anything. The address it checks is fixed and is never a DodoSSH server.
|
||||
|
||||
%install
|
||||
rm -rf %{buildroot}
|
||||
|
||||
# ◆ /opt, AND NOT /usr/lib64/dodossh, WHICH IS THE MORE CONVENTIONAL PLACE.
|
||||
#
|
||||
# The Filesystem Hierarchy Standard reserves /opt for exactly this — an add-on package that arrives
|
||||
# as one self-contained tree — and that is what a published .NET application is: a runtime, thirty
|
||||
# managed assemblies and half a dozen native libraries that only make sense together and are only
|
||||
# ever loaded by each other. Scattering them into the system library directory would mix a bundled
|
||||
# libSkiaSharp in with the system's shared objects, which is the arrangement the provides exclusion
|
||||
# above exists to keep this package out of.
|
||||
#
|
||||
# The directory name carries the channel, so a release and a nightly are two trees rather than one
|
||||
# that overwrites the other. That is the same separation ADR 0013 rule 9 builds on Windows out of
|
||||
# pack ids, arrived at here for free: two RPMs with different names owning different paths cannot
|
||||
# replace one another, and dnf will say so rather than doing it. The profile directory is separated
|
||||
# by the build itself — DodoChannel=nightly is what ClientPaths reads to suffix it — so the two
|
||||
# installs share no state at all.
|
||||
mkdir -p %{buildroot}/opt/%{dodo_dir}
|
||||
cp -a %{dodo_payload}/. %{buildroot}/opt/%{dodo_dir}/
|
||||
|
||||
# ◆ AND THE MODES ARE SET HERE RATHER THAN INHERITED, WHICH IS NOT TIDINESS.
|
||||
#
|
||||
# `cp -a` preserves what the publish directory happened to have, and what it happened to have is a
|
||||
# property of the filesystem it was written on rather than of anything decided here. rpmlint caught
|
||||
# the consequence on the first tree this was tried against: every file in the package was mode 777,
|
||||
# because the tree had come off a mount that reports everything that way.
|
||||
#
|
||||
# For this application that is not a lint warning, it is a hole. A world-writable /opt/DodoSSH lets
|
||||
# any local account replace the SSH client belonging to every other account on the machine — the
|
||||
# binary that holds a team's infrastructure credentials in plaintext while it runs, which is the
|
||||
# thing ADR 0001 spends its whole trust model on. Malware running as nobody-in-particular would not
|
||||
# need to attack the crypto; it would edit the client.
|
||||
#
|
||||
# Stated absolutely and in this order, so the answer does not depend on the publish at all:
|
||||
# directories and the things that are executed or loaded get 0755, and everything else — assemblies,
|
||||
# PDBs, the runtime config, the terminal's HTML and JavaScript — gets 0644. Owned by root, which is
|
||||
# rpm's default for a package that names no %attr user, and is what makes the modes mean anything.
|
||||
find %{buildroot}/opt/%{dodo_dir} -type d -exec chmod 0755 {} +
|
||||
find %{buildroot}/opt/%{dodo_dir} -type f -exec chmod 0644 {} +
|
||||
find %{buildroot}/opt/%{dodo_dir} -type f -name '*.so*' -exec chmod 0755 {} +
|
||||
|
||||
# The two files a .NET publish means to be executed. createdump is the runtime's crash-dump helper
|
||||
# and is only present on some publishes, so it is conditional; the apphost never is, and the check
|
||||
# above this section has already refused a publish without one.
|
||||
chmod 0755 %{buildroot}/opt/%{dodo_dir}/DodoSSH
|
||||
[ -f %{buildroot}/opt/%{dodo_dir}/createdump ] && chmod 0755 %{buildroot}/opt/%{dodo_dir}/createdump
|
||||
true
|
||||
|
||||
# The apphost is the thing to launch and every other file in the tree is loaded relative to it, so
|
||||
# what goes on PATH is a symlink rather than a copy or a wrapper script. .NET resolves its own
|
||||
# directory through /proc/self/exe, which follows the link, so a symlinked apphost finds its runtime
|
||||
# with no help — a wrapper exporting DOTNET_ROOT or cd-ing first would be solving a problem that
|
||||
# does not exist here, and would be one more file to keep in step with the layout.
|
||||
#
|
||||
# Relative — ../../opt from /usr/bin — rather than the absolute /opt path it resolves to. Both work
|
||||
# on a running system and only one works inside a chroot or an image being assembled somewhere other
|
||||
# than /, which is where absolute symlinks out of a package are checked and warned about.
|
||||
mkdir -p %{buildroot}%{_bindir}
|
||||
ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
|
||||
|
||||
# ◆ THE DESKTOP ENTRY IS WRITTEN HERE RATHER THAN SHIPPED AS A SOURCE FILE.
|
||||
#
|
||||
# Three of its seven meaningful lines differ between the release and the nightly, and the two files
|
||||
# that would result are the kind of pair that drifts: somebody adds a MIME type to one of them and
|
||||
# nobody notices for a release. Composed from the same macros the rest of the layout uses, they
|
||||
# cannot.
|
||||
#
|
||||
# StartupWMClass is the line that is easy to leave out and visibly wrong without: Avalonia sets
|
||||
# WM_CLASS from the assembly name, which is DodoSSH for both channels, so without this a launched
|
||||
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
|
||||
# name deliberately and not the package's binary name — the window does not know which package
|
||||
# started it.
|
||||
mkdir -p %{buildroot}%{_datadir}/applications
|
||||
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Version=1.0
|
||||
Name=%{dodo_title}
|
||||
GenericName=SSH Client
|
||||
Comment=SSH and SFTP with an end-to-end encrypted credential vault
|
||||
Exec=%{dodo_bin}
|
||||
Icon=%{dodo_bin}
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
StartupWMClass=DodoSSH
|
||||
Categories=Network;RemoteAccess;System;
|
||||
Keywords=SSH;SFTP;Terminal;Remote;Shell;
|
||||
DESKTOP
|
||||
|
||||
# One file per size into the hicolor theme, named for the package so the two channels show different
|
||||
# entries rather than fighting over one path. The sizes are the ones the icon script draws and its
|
||||
# comment argues for; adding one there means adding it here.
|
||||
for size in 48 64 128 256; do
|
||||
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps
|
||||
install -m 0644 %{dodo_icons}/dodossh-${size}.png \
|
||||
%{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps/%{dodo_bin}.png
|
||||
done
|
||||
|
||||
# ◆ THE UDEV RULE, WHICH IS THE ONE FILE THIS PACKAGE PUTS OUTSIDE ITS OWN TREE.
|
||||
#
|
||||
# It tags /dev/tpmrm0 with uaccess so the person at the machine can use the TPM without being put in
|
||||
# the tss group by an administrator. The rule itself carries the whole argument, including what it
|
||||
# costs and the one-line way to mask it.
|
||||
#
|
||||
# Installed for both channels, and that is not a mistake to be tidied up. The two packages own
|
||||
# different paths for the same reason ADR 0013 rule 9 gives, but this file is about the machine's
|
||||
# TPM rather than about either build — a nightly and a release on one machine want exactly the same
|
||||
# access, and giving each its own copy would mean removing one package silently revoked the other's.
|
||||
# rpm refuses to let two packages own one path with different content, which is what makes this
|
||||
# safe: the file is byte-identical from both, so it is shared rather than duplicated.
|
||||
#
|
||||
# /usr/lib/udev/rules.d hardcoded rather than %{_udevrulesdir}, which comes from systemd-rpm-macros
|
||||
# and is not present on the Alpine host CI builds this on. Hardcoding a path that has been stable
|
||||
# for a decade beats a macro that expands to nothing and silently installs into the buildroot's root.
|
||||
mkdir -p %{buildroot}/usr/lib/udev/rules.d
|
||||
install -m 0644 %{dodo_udev} %{buildroot}/usr/lib/udev/rules.d/71-dodossh-tpm.rules
|
||||
|
||||
# Installed rather than listed straight out of the source tree. `%license` is a %doc-style marker and
|
||||
# resolves its argument against the build directory, which this spec does not have — there is no
|
||||
# %prep and no %build, because the payload arrives already published. Putting the file where it
|
||||
# belongs first and marking the installed path second is the form that works with neither.
|
||||
mkdir -p %{buildroot}%{_licensedir}/%{name}
|
||||
install -m 0644 %{dodo_licence} %{buildroot}%{_licensedir}/%{name}/LICENSE
|
||||
|
||||
# Modern rpm on Fedora fires file triggers that do both of these, so on the distribution this was
|
||||
# measured against they are redundant. They are here for the ones that do not, and every line ends in
|
||||
# `|| :` so that a host without the tool installed gets a package that installs rather than a
|
||||
# scriptlet failure at the end of a 130 MB transaction.
|
||||
%post
|
||||
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
|
||||
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
|
||||
|
||||
# Reloaded and replayed, because a rule that only takes effect at the next boot is a feature that
|
||||
# does not work on the day it is installed — and "reboot and try again" is the worst possible answer
|
||||
# to "why is the PIN option missing". The trigger is scoped to the tpmrm subsystem so this re-runs
|
||||
# rules for one device rather than for every device on the machine.
|
||||
/usr/bin/udevadm control --reload &>/dev/null || :
|
||||
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
|
||||
|
||||
%postun
|
||||
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
|
||||
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
|
||||
|
||||
# On the way out too, so removing the package takes the ACL with it rather than leaving the last
|
||||
# session's user holding access to the TPM until the next reboot.
|
||||
/usr/bin/udevadm control --reload &>/dev/null || :
|
||||
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
|
||||
|
||||
%files
|
||||
# The whole tree, owned by this package, which is what makes `dnf remove` take all of it. The user's
|
||||
# vault cache is not in here and is not touched by an uninstall — ClientPaths puts it under
|
||||
# $XDG_DATA_HOME, which is the Linux spelling of the same decision ADR 0013's consequences record for
|
||||
# Windows: the install moves, the profile does not, and an uninstall is not a sign-out.
|
||||
/opt/%{dodo_dir}
|
||||
%{_bindir}/%{dodo_bin}
|
||||
%{_datadir}/applications/%{dodo_bin}.desktop
|
||||
%{_datadir}/icons/hicolor/*/apps/%{dodo_bin}.png
|
||||
/usr/lib/udev/rules.d/71-dodossh-tpm.rules
|
||||
%license %{_licensedir}/%{name}/LICENSE
|
||||
|
||||
%changelog
|
||||
# Deliberately empty. The changelog of record is the git history and the release notes on the forge,
|
||||
# and a %changelog generated per build from a version number carries no information that `rpm -q`
|
||||
# does not already answer — while a hand-written one is a third place a release has to remember to
|
||||
# edit. rpmlint says W: no-changelog and that warning is accepted.
|
||||
@@ -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": [
|
||||
|
||||
@@ -107,6 +107,81 @@ almost nil. Two things to check when it is taken up: `EnrollmentValidation` pins
|
||||
`DevicePublicKey` to `CryptoSpec.PublicKeySize` (32 bytes; a P-256 public key is 33 or 65), and the
|
||||
envelope's minimum-length rule.
|
||||
|
||||
### 4. Linux: sealed into the TPM behind a PIN the TPM checks
|
||||
|
||||
Added after the Linux head was packaged ([ADR 0013](0013-desktop-distribution-and-updates.md) decision 11).
|
||||
The third desktop platform reached this file the way the first two did — by having to answer the amendment
|
||||
above — and it is the only one where the answer is not a clean yes.
|
||||
|
||||
**The mechanism.** The 32-byte device key is sealed into a TPM 2.0 keyedhash object whose `userAuth` is
|
||||
derived from a PIN. Unsealing is a command the TPM refuses without it. The blob on disk is encrypted to the
|
||||
machine's storage hierarchy, so it is useless on any other machine, and nothing in this application ever
|
||||
compares the PIN. The parent is an ECC P-256 primary rederived from a fixed template on every use rather
|
||||
than made persistent — a persistent handle would occupy an NV slot forever, need cleaning up on withdrawal,
|
||||
and collide with whatever else on the machine chose the same index. ECC rather than RSA because creating an
|
||||
RSA primary on a discrete TPM takes seconds and this runs on the unlock path.
|
||||
|
||||
**The sealed object deliberately does not carry `NoDA`, and that single attribute is what makes a
|
||||
four-digit PIN defensible.** Every wrong answer advances the TPM's dictionary-attack counter and the chip
|
||||
stops answering entirely once it trips — measured against a software TPM at four wrong tries, after which
|
||||
the *correct* PIN gets nothing either. That is the property a short secret has here and cannot have in a
|
||||
file, and it is asserted by a test rather than left to a reading of the template, because removing the
|
||||
attribute would leave every other assertion in that file passing.
|
||||
|
||||
◆ **This is weaker than Windows Hello and Touch ID, and the difference is invisible to the user.** Both of
|
||||
those raise their prompt from inside the platform — a secure desktop, a system sheet — so the process asking
|
||||
never sees the secret. Linux offers nothing to borrow, so the PIN is typed into a window this application
|
||||
draws, and an attacker already running as the user could draw a convincing copy of it. Two things bound
|
||||
that, and neither erases it: the same attacker could counterfeit the passphrase screen, which is a much
|
||||
larger prize, so the feature creates no capability that did not exist; and what they would obtain is a PIN
|
||||
that is worthless without this machine's TPM, where the passphrase is worth every machine. **The parity is
|
||||
not claimed anywhere in the interface**, which is why `DesktopDeviceKeyStores` documents the ordering and
|
||||
why the prompt is deliberately plain rather than dressed to look like a system dialog — teaching people
|
||||
that a convincing DodoSSH PIN box is a system dialog is the one thing this design must not do.
|
||||
|
||||
**Reaching the TPM at all is the other half, and the package now arranges it.** On Fedora `/dev/tpmrm0` is
|
||||
`root:tss` mode 0660 and desktop users are not in `tss`, so without help the ordinary case is a working TPM
|
||||
this process may not open — which reports unavailable and keeps asking for the passphrase, exactly as a
|
||||
machine with no TPM does.
|
||||
|
||||
◆ **This originally said shipping a udev rule to widen that was refused, on the grounds that it is a
|
||||
system-wide decision an application package should not make. That refusal was overturned deliberately, and
|
||||
the reasoning it was replaced with is narrower rather than louder.** The refusal treated "widen access" as
|
||||
one thing. It is two, and they are not close:
|
||||
|
||||
- *Adding a person to `tss`* is what the refusal was really about, and it remains the wrong answer. It is
|
||||
permanent, it applies whether or not they are at the machine, it applies over SSH, and it needs an
|
||||
administrator and a fresh login before the feature appears at all.
|
||||
- *Tagging `/dev/tpmrm0` with `uaccess`* is the mechanism systemd already uses for `/dev/dri`, webcams,
|
||||
scanners and FIDO keys: logind grants a POSIX ACL to the user of the **active local session** and removes
|
||||
it when that session ends. Not over SSH, not on an inactive VT, not to daemons. `build/linux/71-dodossh-tpm.rules`
|
||||
is that one line, and it changes neither the mode nor the group, so anything that worked before still does.
|
||||
|
||||
**`tpmrm` and never `tpm`, which is what makes it defensible at all.** `/dev/tpm0` is the raw device and is
|
||||
exclusive; a process holding it can leave the chip in a state that breaks every other user. `/dev/tpmrm0` is
|
||||
the kernel's resource manager, which virtualises handles and isolates callers — it exists *because* the TPM
|
||||
is meant to be usable by more than one unprivileged program. Granting a seat's user the manager is the use
|
||||
it was designed for.
|
||||
|
||||
**The cost is a denial of service and not a disclosure, and it is stated because it is easy to miss.** TPM
|
||||
objects are protected by their own auth values, so this lets nobody read anybody else's sealed data. What it
|
||||
does allow is *failed* authorisation attempts — and the chip answers those by counting them and eventually
|
||||
refusing everyone, which is the same lockout decision 4 relies on. On a machine whose disk is unlocked by
|
||||
the TPM at boot (`systemd-cryptenroll`, Clevis), somebody sitting at it can trip that lockout and stop the
|
||||
next boot from unlocking until it recovers. An administrator who does not accept that masks the file with
|
||||
`ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules`, which is the standard override, survives upgrades,
|
||||
and puts the behaviour back to the passphrase-only path. The rule says so in its own comments, because the
|
||||
person who needs to know is reading the file rather than this document.
|
||||
|
||||
**What was rejected, and why each would have failed the amendment above.** The Secret Service API —
|
||||
gnome-keyring or KWallet — is out of process and OS-managed and has *no per-use consent*: once the session
|
||||
keyring is unlocked anything running as the user reads the key silently, which is DPAPI's shape exactly and
|
||||
what "Why not DPAPI alone" already refuses. `fprintd` is an authentication oracle rather than a key store:
|
||||
it answers a question and holds nothing, so skipping the call skips the gate — the amendment's mistake with
|
||||
a fingerprint reader attached. A polkit-gated root helper would genuinely work and was declined on surface
|
||||
area: it puts a privileged component in a package that installs nothing setuid, to reach a property the TPM
|
||||
already provides.
|
||||
|
||||
## Consequences
|
||||
|
||||
### The cache key had to move, and the spec changed
|
||||
@@ -138,9 +213,11 @@ would have become false under DPAPI alone. A gesture is still something the atta
|
||||
|
||||
### Operational
|
||||
|
||||
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
||||
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
||||
is therefore required, not a nicety.
|
||||
- **A TPM is not always there, and on Linux it is often there and out of reach.** A machine without one gets
|
||||
a store that reports itself unavailable, so unlock keeps asking for the passphrase and neither affordance
|
||||
appears in the interface. The passphrase path is therefore required, not a nicety — and on Linux it is the
|
||||
default rather than the exception, because the device node is not readable by an ordinary desktop user
|
||||
until somebody says it should be. See decision 4.
|
||||
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
|
||||
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
|
||||
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
|
||||
|
||||
@@ -316,6 +316,106 @@ preferences screen, which is the screen somebody reads when asked which nightly
|
||||
across the floor holds: heights rise within a floored version, and the first real tag moves past all of
|
||||
them. The release script gets no floor and must not have one; there, `0.0.0` should be refused.
|
||||
|
||||
### 11. Linux is a third desktop platform, and it is a package rather than an updater
|
||||
|
||||
The Linux head existed and shipped as nothing: a checkout somebody built. `VelopackUpdateChannel` already
|
||||
named the two platforms it serves rather than negating the one it does not, on the reasoning that "a future
|
||||
AppImage should be an addition here rather than a thing that silently already half-happened". This is that
|
||||
addition, and it is not an AppImage.
|
||||
|
||||
**The package is an RPM and nothing inside it replaces anything.** That is the decision, and everything
|
||||
else follows from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been
|
||||
repeated a third time — one more channel, one more index, one more client swapping its own files over. It
|
||||
should not be, because this platform already has the mechanism that whole apparatus is a substitute for.
|
||||
`dnf` is an updater the user already trusts, already audits and already controls, and it updates the
|
||||
runtime's own dependencies besides. Adding a second one that answers to this project instead would be
|
||||
building the capability decision 4 spends a page keeping unsettable, on the one platform where nobody had
|
||||
to.
|
||||
|
||||
◆ **The first version of this rule said "there is no updater in it at all", and that went too far.** It
|
||||
conflated two things the other two platforms happen to do together: *noticing* that a newer build exists,
|
||||
and *installing* it. The second is what the argument above is about, and the first is not — a client that
|
||||
checks a fixed address and tells you what it found has taken no capability from anybody. Written as a flat
|
||||
refusal, the rule bought its safety by paying the cost in the paragraph below, and the cost was avoidable.
|
||||
|
||||
**So the split is: this client checks and fetches, and PackageKit installs.** `LinuxUpdateChannel` reads
|
||||
the same rolling release the Windows nightly feed lives on, compares versions the way `rpm` compares them,
|
||||
downloads the package, verifies its digest, and hands the file to PackageKit — which raises polkit and asks
|
||||
the user, who may say no. `ApplyingEndsTheProcess` is false and nothing is torn down before the answer, which
|
||||
is the phone's shape rather than the desktop's, and is already what `MainWindowViewModel` does with that
|
||||
flag. Four things follow that are worth stating because each is a property the Velopack channels do not have
|
||||
to think about:
|
||||
|
||||
- **The comparison is `rpmvercmp` and not SemVer.** Decision 7 made one version of record for the whole
|
||||
repository, and this is the one place it is not the string that decides: `rpm` will accept or refuse the
|
||||
install, so the client must compare the way `rpm` does or it can offer an update PackageKit then declines.
|
||||
It is the same argument [ADR 0014](0014-android-updates.md) makes for comparing `versionCode`. The
|
||||
manifest therefore carries both — the RPM version to compare and the SemVer to show a person — and the
|
||||
installed side is read out of the rpm database rather than derived from the assembly, so the translation
|
||||
`scripts/ci-linux-rpm.sh` performs exists in exactly one place.
|
||||
- **The digest is checked, and it is not a signature.** Velopack verifies what it downloads and Android
|
||||
refuses a package whose signature does not match the installed one. An RPM fetched over HTTPS and handed
|
||||
to a package manager has neither, so the manifest names a SHA-256 and the client refuses anything else.
|
||||
That catches a truncated download and a caching proxy and says nothing whatever about provenance, because
|
||||
the same job writes the package and the manifest. Provenance here is decision 4's constant address.
|
||||
- **`ONLY_TRUSTED` is cleared, deliberately.** The package is unsigned, so requiring PackageKit to trust it
|
||||
would fail every update at the last step. Clearing the flag routes the request to polkit's
|
||||
`package-install-untrusted` action, which asks for an administrator rather than proceeding quietly — the
|
||||
user is told what they are agreeing to, which is the honest form of an unsigned package.
|
||||
- **Nothing restarts.** Replacing files under a running process is ordinary on Linux and the old inodes stay
|
||||
mapped, so the build keeps running until somebody closes it. That is the same courtesy decision 1's
|
||||
consequences argue for on Windows, arrived at for free.
|
||||
|
||||
The honest cost that remains, recorded because it is the part that will be argued with: **discovery is still
|
||||
worse here than on either other desktop, for anybody who declines the in-app check.** There is no package
|
||||
repository to subscribe to, so `dnf upgrade` on its own will never bring a newer DodoSSH; the in-app check is
|
||||
what stands in for one, and a user who turns it off on PREFERENCES → UPDATES has no other signal. A signed
|
||||
repository is the fix, it is not built, and decision 11's last paragraph says what it would cost.
|
||||
|
||||
**CI publishes it, and only to the nightly channel.** Rule 3 is untouched and applies with its full force:
|
||||
the token that writes a release is the capability to ship somebody a build, and it stays off runners. What
|
||||
changes is nothing, because rule 9 already established a channel CI may publish to and this is a second
|
||||
artefact on it. The release-shaped package is built on every `main` build and on every tag and uploaded
|
||||
nowhere, exactly as the Windows and macOS packages are, which proves the `linux-x64` restore graph and the
|
||||
packaging without putting an installer anybody has run in a place that invites passing it on.
|
||||
|
||||
Note what CI's check is and is not. `vpk` verifies that the main executable actually calls
|
||||
`VelopackApp.Build().Run()` — a real assertion about the application, made for free by the packager. There
|
||||
is no equivalent here, so `scripts/ci-linux-rpm.sh` makes its own: it asks the finished package what it
|
||||
requires, what it provides, what files it holds and what modes they carry, and fails on each way a package
|
||||
can build cleanly and then be uninstallable or unsafe. Two of those checks exist because the first realistic
|
||||
payload tripped them — .NET's `libmscordbi.so` links `libmscordaccore.so`, which with bundled provides
|
||||
excluded produces a dependency no host can satisfy; and `cp -a` off the wrong filesystem produced a
|
||||
world-writable `/opt/DodoSSH`, which is a local privilege escalation against an application holding
|
||||
plaintext credentials.
|
||||
|
||||
**The separations of rule 9 survive translation, and cost less here.** Two RPMs with different names owning
|
||||
different paths cannot replace one another and `dnf` says so, where on Windows the same property had to be
|
||||
built out of pack ids. The profile split is unchanged and still belongs to the build: `DodoChannel=nightly`
|
||||
is what `ClientPaths` reads, and the packaging must not answer that question a second time.
|
||||
|
||||
**The device key store is settled separately and is not nothing.** The first version of this rule said this
|
||||
platform had none and that the passphrase was asked for at every unlock. It has one:
|
||||
[ADR 0007](0007-device-key-protection.md) decision 4 adds a TPM-sealed key released against a PIN the TPM
|
||||
itself checks and counts failures against. It is weaker than Hello and Touch ID in a way that ADR names
|
||||
precisely. Reaching the chip needs no group membership, because this package ships the udev rule that gives
|
||||
the seated user an ACL on `/dev/tpmrm0` — a decision that ADR overturned from its own first answer, and
|
||||
whose cost it states.
|
||||
|
||||
**What this platform does not have is a signature on the package**, which is not decision 8's "unsigned for
|
||||
now": nothing on this platform
|
||||
refuses or warns about an unsigned RPM, so there is no dialog to buy a certificate to remove. What stands in
|
||||
for one is the same thing that stands in for it on the forge — TLS, and a project that publishes from one
|
||||
address. An RPM repository would change that, because repository metadata is signed and a repository is a
|
||||
thing users add once and then trust; that is the point at which a key becomes necessary rather than optional,
|
||||
and it is the reason building one is a decision and not a chore.
|
||||
|
||||
**x86_64 only, for decision 10's reason and not a different one.** aarch64 is one argument to the publish
|
||||
and one to the script. What is missing is somebody to run Phase 19 on such a machine, and this project does
|
||||
not ship desktop builds nobody has run. The same sentence rules out claiming RHEL 9, which ships WebKitGTK's
|
||||
4.0 API line under a soname the package deliberately does not accept: `dnf` refusing with *nothing provides
|
||||
libwebkit2gtk-4.1.so.0* is a better answer than an install that draws a blank terminal.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
|
||||
@@ -369,6 +469,10 @@ ADR 0011 changes.
|
||||
genuinely the nicest onboarding available and will be proposed again.
|
||||
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
|
||||
inherit it.
|
||||
- **A Velopack channel for Linux.** The mechanism exists — Velopack builds an AppImage — and repeating
|
||||
decisions 1 through 9 a third time would have been the consistent-looking move. Declined in decision 11
|
||||
because this is the one desktop platform that already has an updater the user controls, and a second one
|
||||
answering to this project is the capability decision 4 exists to make unsettable.
|
||||
- **Azure Trusted Signing**, for now, on custody grounds rather than on mechanics. See decision 8.
|
||||
- **A hand-edited version property.** Considered seriously against MinVer, and it loses on the thing that
|
||||
matters here: the tag is already the version of record for the container image, and two places to write
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -300,7 +300,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
|
||||
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
|
||||
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
|
||||
> | No pins destination in the design at all | **Kept anyway.** The rail still carries Pins — `KnownHostsScreen` — because the mock has no screen for approved host keys and this application's has to stay reachable. |
|
||||
> | No pins destination in the design at all | **The rail agrees with the design now.** `KnownHostsScreen` is still built and still reachable — from **Host keys** on the Keys screen's own header, which was always the second way in — but the rail's Pins row is gone. It was kept through v5b on the grounds that the mock has no screen for approved host keys, which is a reason for the screen to exist and was never a reason for a rail entry once the keychain had a door to the same place. Two rail rows landing on one screen is a rail that has to be read twice. |
|
||||
> | The popover's Settings and Preferences rows, and the design's own Settings-* family of screens | **Landed in v5c.** What was two doors to one room in v5b — Settings and Preferences both opening the same bare `Preferences` screen — is now two of three doors onto their own settings pages: Settings opens General, Preferences opens Preferences, and a third row, Vaults, opens Vaults. All three are real, distinct pages inside one settings mode; see the v5c section, below. |
|
||||
> | `· Org` after the user chip's name, and a `Primary` tag on a vault row in the popover | Neither. There is no organisation concept behind a vault — only the vault itself — and no vault is distinguished as primary; the popover's vault rows are the existing shown-vaults toggles, restyled. |
|
||||
> | The design's titlebar, which has nowhere for a sync indicator | `SYNCED` stays, on the titlebar's right side, ahead of the window's own minimise/maximise/close buttons — the one thing this titlebar keeps that the design's own does not draw at all. |
|
||||
@@ -720,3 +720,48 @@ grid of cards with a drawer — see above. The split it describes did not change
|
||||
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
|
||||
sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire,
|
||||
`IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected.
|
||||
|
||||
---
|
||||
|
||||
## v5c-4 — two more, asked for after living with v5b
|
||||
|
||||
**The session shell's host header is gone, and it is a deliberate departure from the design.**
|
||||
`Terminal.dc.html` and `SFTP.dc.html` both draw a 60-pixel row above the pane carrying the address on the
|
||||
left and a cross-surface button on the right, and v5b shipped it as `SessionHeader.axaml`. Both of the two
|
||||
facts it held now live at the head of the sidebar beside the pane — the address as its own line, and the
|
||||
button stretched across the column under it — and the pane is 60 pixels taller for it. The reasoning is the
|
||||
one the design cannot see from a mock: this is a window somebody keeps a terminal open in all day, and a
|
||||
full-width strip repeating an address the tab already names was the cheapest 60 pixels in the layout to give
|
||||
back. `LayoutHarness.SessionScreenHeight` no longer subtracts a header, for the same reason it stopped
|
||||
subtracting the retired window-wide tab strip.
|
||||
|
||||
**The sidebar closes, which the design has no state for.** 300 pixels of a 1081-pixel minimum is a lot to
|
||||
spend on a list that is often two rows long, so `MainWindowViewModel.IsSessionSidebarOpen` folds the column
|
||||
to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel
|
||||
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.
|
||||
+708
-29
@@ -28,8 +28,9 @@ a phase had nothing left for a person to do, which is the good outcome rather th
|
||||
|
||||
### 1.1 No screen is sliced at the WebView's left edge · **the important one**
|
||||
|
||||
Open two terminals, then visit every nav rail entry in turn — Hosts, Keys, Pins, Snips, Logs — and both of
|
||||
the switcher's other two segments, SFTP and S3, at the rail's own head.
|
||||
Open two terminals, then visit every nav rail entry in turn — Hosts, Keys, Snips, Logs — and both of
|
||||
the switcher's other two segments, SFTP and S3, at the rail's own head. The pins screen is no longer a rail
|
||||
entry; reach it from **Host keys** on the Keys screen's header and check it the same way.
|
||||
|
||||
**Pass:** each screen draws whole, its buttons all clickable, and the nav rail stays up the left edge for
|
||||
every one of them. Since v5b's chrome pass the rail is permanent furniture — it no longer collapses for
|
||||
@@ -117,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.
|
||||
@@ -133,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
|
||||
@@ -164,12 +168,122 @@ 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
|
||||
and this application's own quick-connect card was built to reach past.
|
||||
|
||||
### 1.10 A terminal left alone for a long time is still a terminal · **needs an hour, or a debugger**
|
||||
|
||||
Open a shell, leave the terminal for another screen — HOSTS, FILES, anything — and leave the application
|
||||
alone for long enough that the window has been in the background for the better part of an hour. Locking the
|
||||
machine or letting it sleep counts and is the easier way to get there. Come back and click the session's
|
||||
tab.
|
||||
|
||||
**Pass:** the pane is exactly where it was and takes input straight away. If anything is shown at all it is
|
||||
`Reconnecting the terminal view…` for a moment, in the second or so before the socket is back — never a
|
||||
status that is still there after that.
|
||||
|
||||
**Both of the page's own rules here are covered by `RendererReconnectionTests`**, which runs `terminal.js`
|
||||
itself in a fake browser — so a failure of this check is more likely to be the WebView behaving unlike that
|
||||
fake than the page's logic being wrong. That is exactly the division: the test owns the logic, this owns the
|
||||
platform.
|
||||
|
||||
**Failure means:** a banner that stays up is the page's retry not running. It is a `setTimeout` chain, and a
|
||||
chain is what a WebView is entitled to throttle or freeze while nobody is looking at the page; `terminal.js`
|
||||
answers that with wake-ups on `visibilitychange`, `focus` and `online`, none of which can be throttled,
|
||||
plus a watchdog for a handshake that never finishes. A banner that flickers on and off every second or two
|
||||
instead is the opposite fault — a reconnect loop, in which each attempt displaces the socket before it
|
||||
through `TerminalDataPlane.UpgradeAsync`'s takeover and the displaced socket's close schedules the next.
|
||||
That is what the "is this still the page's socket" guard in `connect()` exists to stop. A pane that takes no
|
||||
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
|
||||
@@ -333,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
|
||||
@@ -400,6 +582,82 @@ surface. Use the ← button, or drag the file to the local pane first.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2b — The path box, and what can be done to a remote row
|
||||
|
||||
The rules are automated — `SftpPath.Resolve` in `RemotePathTests`, the three questions and the automatic
|
||||
refresh in `ShellFlowTests`, both new shapes measured in `ScreenLayoutTests`. What is left here is what
|
||||
those cannot reach: a real server's own refusals, and a soft keyboard.
|
||||
|
||||
Connect the SFTP page to a host first.
|
||||
|
||||
### 2.20 Typing a path
|
||||
|
||||
Click into the path in the remote pane's header, type `/var/log`, press Enter. Then type `..`, Enter. Then
|
||||
`~`, Enter.
|
||||
|
||||
**Pass:** each one lists, the breadcrumb underneath follows, and the box ends up showing the absolute path
|
||||
that was actually reached rather than what was typed.
|
||||
|
||||
### 2.21 Escape puts the path back
|
||||
|
||||
Type nonsense into the box without pressing Enter, then press Escape.
|
||||
|
||||
**Pass:** the box goes back to the directory the pane is showing.
|
||||
|
||||
### 2.22 A path the server refuses
|
||||
|
||||
Type a path that does not exist, and one that exists but is not readable by this account.
|
||||
|
||||
**Pass:** the pane stays where it was, the status line at the foot of the screen carries the server's own
|
||||
message, and the box still holds what was typed so it can be corrected.
|
||||
|
||||
### 2.23 Rename
|
||||
|
||||
Select a file, press the rename button, change the name, press RENAME.
|
||||
|
||||
**Pass:** the listing comes back with the new name and the row still selected. Then try a name that another
|
||||
file in the directory already has: **the server refuses it and says so, and nothing is overwritten** — this
|
||||
is the one that matters, because the whole queue's "nothing lands at its final name until it is complete"
|
||||
promise rests on the same server behaviour.
|
||||
|
||||
### 2.24 Move
|
||||
|
||||
Select a file, press the move button, type a directory — try an absolute one and a relative one — and press
|
||||
MOVE.
|
||||
|
||||
**Pass:** the file leaves the listing and is in the directory that was typed.
|
||||
|
||||
Then try one across a filesystem boundary on the same host — `/tmp` to a path on a separate mount is the
|
||||
usual pair. **Pass:** the server refuses with its own message about a cross-device link. This cannot be
|
||||
faked in a test and is the refusal somebody will eventually meet.
|
||||
|
||||
### 2.25 The listing refreshes itself after an upload
|
||||
|
||||
Upload a file into the directory showing.
|
||||
|
||||
**Pass:** it appears in the remote listing without REFRESH being pressed. Then upload into a *different*
|
||||
directory: nothing on screen changes. Then download a file into the folder the local pane is showing, and
|
||||
that one appears on the left the same way.
|
||||
|
||||
### 2.26 A transfer that lands while a question is open
|
||||
|
||||
Start an upload into the directory showing, and while it runs select a row and press DELETE so the question
|
||||
is up when the transfer finishes.
|
||||
|
||||
**Pass:** the listing does not move while the question is on screen, and refreshes the moment the question
|
||||
is answered or cancelled.
|
||||
|
||||
**Failure means:** the row the question was asked about is not necessarily the row that gets acted on.
|
||||
|
||||
### 2.27 The phone's GO button
|
||||
|
||||
On Android, connect on the Files screen, type a path into the box above the breadcrumb and press GO.
|
||||
|
||||
**Pass:** it lists. The keyboard's own return key may or may not commit — that is the IME's choice, which is
|
||||
why GO exists — but GO always must.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Host groups and snippets
|
||||
|
||||
Two synced item kinds, two ways of drawing a group — the desktop's grid of cards, one level at a time, and
|
||||
@@ -1584,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.
|
||||
@@ -1737,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
|
||||
@@ -1750,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
|
||||
|
||||
@@ -1796,6 +2165,49 @@ worse than saying nothing: the banner exists so this is never silently wrong. If
|
||||
banner is there, the session's credit window was not reset on reattach and the shell is frozen behind it —
|
||||
see `TerminalWorkspace.ReplayAfterAttachAsync`.
|
||||
|
||||
### 11.12a Coming back to a terminal screen left alone for a long while
|
||||
|
||||
The same shape as 11.12 and a different trigger: rather than backgrounding the app, stay in it. With a shell
|
||||
open, leave the terminal for HOSTS, FILES or MORE — which collapses the renderer to GONE, so the page is
|
||||
hidden by Chromium's reckoning — and leave the phone alone for at least ten minutes with the screen off.
|
||||
Then come back to the app and to the terminal.
|
||||
|
||||
**Pass:** the pane is there and takes input at once, or reconnects visibly within about a second of the
|
||||
screen appearing. `Reconnecting the terminal view…` on the way in is fine; still being there once the
|
||||
terminal has been on screen for a couple of seconds is not.
|
||||
|
||||
**Failure means:** the page's retry did not survive being hidden. A hidden WebView has its timers throttled
|
||||
— once a minute after five minutes hidden — and a renderer that was frozen or reclaimed runs none of them,
|
||||
which is why `terminal.js` does not rely on the timer alone: `visibilitychange` is the event that says the
|
||||
screen is back, and it reconnects immediately rather than waiting to be asked twice. Ten minutes is chosen
|
||||
to clear the five-minute threshold with room to spare.
|
||||
|
||||
**Not covered by either check, and worth knowing:** a socket that is *open and dead* — the connection gone
|
||||
without either end noticing, which a suspended renderer can leave behind — shows no banner at all, because
|
||||
`readyState` still reads OPEN and nothing on this page probes further. The symptom is a terminal that looks
|
||||
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
|
||||
@@ -2423,7 +2835,9 @@ script warns rather than failing when that is legitimate, which is the first rel
|
||||
### 16.7 The update arrives, and the restart lands in it · **the whole point of the work**
|
||||
|
||||
With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours.
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours. Closing and
|
||||
reopening the application does the same thing without the button: the first pass of the loop runs at launch,
|
||||
so a client started after a release finds it without anybody asking.
|
||||
|
||||
**Pass:** the progress bar moves, the banner appears above the status bar, and — the part to actually watch
|
||||
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
|
||||
@@ -2437,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.
|
||||
@@ -2482,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
|
||||
@@ -2713,3 +3190,205 @@ Register a device, sync something, then remove the application.
|
||||
**Failure means:** an empty directory is the pack id having been changed to `DodoSSH`, which puts Velopack's
|
||||
install root on top of `ClientPaths.DataDirectory` and makes an uninstall delete a user's un-synced work.
|
||||
This is the single reason the bundle is named `DodoSSH.Desktop.app`.
|
||||
|
||||
## Phase 19 — Installing the Linux client, and the things dnf does not check
|
||||
|
||||
The Linux counterpart of phases 16 and 18. One of their questions is off the table — there is no signing,
|
||||
because nothing on this platform refuses an unsigned package, which is a distinct statement from "unsigned
|
||||
is fine"; `build/linux/dodossh.spec` says what stands in for a signature, which is that the package comes
|
||||
from the project's own forge over TLS and from nowhere else.
|
||||
|
||||
Everything else is here, and three things are here that neither other phase has. **The update does not
|
||||
install itself**, so 19.8 is about watching a polkit prompt appear and saying no to it, where phases 16 and
|
||||
18 watch an application replace itself. **The device key's gate is a PIN rather than a gesture**, with the
|
||||
TPM's own lockout behind it — so 19.7 deliberately breaks something and then tells you how to put it back.
|
||||
And **this package changes a permission on the machine**: 19.4 and 19.5 are about the udev rule that gives
|
||||
the seated user the TPM, checking both that it works and that masking it undoes it completely.
|
||||
|
||||
What this phase is for is the set nobody else can answer. **CI builds this package and installs nothing**,
|
||||
and every check in `scripts/ci-linux-rpm.sh` is a check on what the package *says* — its dependencies, its
|
||||
file list, its modes. The code does better: `LinuxDeviceKeyStoreTests` runs the whole seal, unseal,
|
||||
wrong-PIN and lockout path against a software TPM, and `LinuxUpdateChannelTests` covers the version
|
||||
comparison and the digest. What none of them touch is `/dev/tpmrm0`, PackageKit, polkit and a real desktop
|
||||
session, and the failures below are all of the shape "installs perfectly and then does not work".
|
||||
|
||||
Needs a Fedora 44 machine or newer with a graphical session. Build the package first:
|
||||
|
||||
```bash
|
||||
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||
--configuration Release --runtime linux-x64 --self-contained true \
|
||||
-p:RestoreLockedMode=false --output /tmp/linux-x64
|
||||
scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
|
||||
sudo dnf install /tmp/rpm/dodossh-1.2.3-1.x86_64.rpm
|
||||
```
|
||||
|
||||
### 19.1 It launches from the menu, and not only from a shell · **do this one first**
|
||||
|
||||
Log out and back in, or wait for the desktop to notice the new `.desktop` file, then start DodoSSH from the
|
||||
applications menu rather than from a terminal.
|
||||
|
||||
**Pass:** the window opens, and the running application's icon in the dock or task list is the same entry
|
||||
that launched it — one icon, not two.
|
||||
|
||||
**Failure means:** a second unlabelled icon appearing beside the launcher is `StartupWMClass` not matching
|
||||
what Avalonia sets `WM_CLASS` to. The spec sets it to `DodoSSH`, which is the assembly name and is the same
|
||||
for both channels; if Avalonia's answer has changed, `xprop WM_CLASS` on the window is what to compare
|
||||
against. Nothing at all in the menu is the desktop database not having been rebuilt — `%post` runs
|
||||
`update-desktop-database` and swallows its failure on purpose, so try it by hand before concluding anything.
|
||||
|
||||
### 19.2 The terminal draws, which is the WebKitGTK question
|
||||
|
||||
Connect to a host and use the shell: type, run something that scrolls, resize the window.
|
||||
|
||||
**Pass:** the terminal attaches within a second or two and behaves as it does on Windows.
|
||||
|
||||
**Failure means:** **a blank pane where everything else works is the known one**, and it is the reason
|
||||
`MainWindow.OnTerminalEnvironmentRequested` exists — the GTK WebView adapter in its default mode can only
|
||||
open a window of its own, so a control asked to host it in place has nothing to draw. If that method has
|
||||
stopped setting `ExperimentalOffscreen`, or the vendor has withdrawn the flag, this is what it looks like.
|
||||
A pane that reports a renderer timeout instead is the loopback WebSocket rather than the backend.
|
||||
|
||||
### 19.3 The icon is the mark, at the sizes the desktop actually asks for
|
||||
|
||||
Look at it in the applications menu, in the dock, and in the window switcher.
|
||||
|
||||
**Pass:** the accent tile and the `>_` mark, filling its square, legible at the small end.
|
||||
|
||||
**Failure means:** a generic icon is `Icon=` in the desktop entry not matching an installed file name — both
|
||||
are the package's binary name, so they can only disagree if one was edited. A blurry small icon is the
|
||||
48-pixel file missing and the 256 being scaled down; `dodossh-icon.ps1` draws four sizes and the spec
|
||||
installs four.
|
||||
|
||||
### 19.4 The udev rule gives *this* session the TPM, and gives nobody else it · **needs a real TPM**
|
||||
|
||||
Installing the package should be the whole setup — no group, no administrator, no fresh login. Check it
|
||||
from a graphical session on the machine itself:
|
||||
|
||||
```bash
|
||||
getfacl /dev/tpmrm0 | grep "^user:"
|
||||
id -nG | tr ' ' '
|
||||
' | grep -x tss # expect nothing: you are not in the group
|
||||
```
|
||||
|
||||
**Pass:** `getfacl` shows a `user:<you>:rw-` entry that is **not** there for any other account, and you are
|
||||
not in `tss`. Now `ssh` into the same machine from elsewhere and run the same `getfacl`: the entry for you
|
||||
is still the *local* session's, and a shell that is not on the seat cannot open the device — `head -c1
|
||||
/dev/tpmrm0` answers "Permission denied".
|
||||
|
||||
**Failure means:** no ACL at all is the rule not having been applied. `udevadm test --action=change
|
||||
/sys/class/tpmrm/tpmrm0 | grep uaccess` says whether the tag is being set, and the load order matters —
|
||||
71 has to fall between `60-tpm-udev.rules` and `73-seat-late.rules`, which is what turns the tag into an
|
||||
ACL. If the file is present and untagged, check nothing in `/etc/udev/rules.d` is masking it. **An ACL
|
||||
granted to a remote session would be the serious one**, and would mean the tag had been put on `tpm` rather
|
||||
than `tpmrm` or the mode widened instead — neither of which this rule does.
|
||||
|
||||
### 19.5 Masking the rule puts the machine back the way it was
|
||||
|
||||
```bash
|
||||
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
|
||||
sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=tpmrm --action=change
|
||||
```
|
||||
|
||||
Log out and back in, then open the security settings page.
|
||||
|
||||
**Pass:** registering a device key is **not** offered, and unlocking asks for the passphrase every time —
|
||||
the same behaviour as a machine with no TPM. `udevadm test` reports the file as "masked by previous entry".
|
||||
|
||||
**Failure means:** still being offered it means `LinuxDeviceKeyStore.IsSupported` answered from the presence
|
||||
of the device node rather than from opening it. Accepting that offer would put a wrap on the server that
|
||||
nothing can ever open, and the failure would arrive at the next unlock rather than now.
|
||||
|
||||
**Then undo it** — `sudo rm /etc/udev/rules.d/71-dodossh-tpm.rules` and reload — before 19.6.
|
||||
|
||||
### 19.6 The PIN unlocks, and the TPM is what checks it
|
||||
|
||||
Register a device key from the security settings page and choose a PIN. Lock the vault and unlock it again.
|
||||
|
||||
**Pass:** registering asks for a PIN twice and shows no other prompt. Unlocking asks for it once and opens
|
||||
the vault. Cancelling that prompt lands on the passphrase screen, which works.
|
||||
|
||||
**Failure means:** a PIN prompt that opens the vault whatever you type would mean the unseal is not reaching
|
||||
the TPM at all, which is the one failure that would make this whole feature decorative.
|
||||
|
||||
### 19.7 A wrong PIN gets nothing, and the TPM stops answering
|
||||
|
||||
With a device key registered, lock the vault and enter a wrong PIN. Do it several times.
|
||||
|
||||
**Pass:** each attempt lands on the passphrase screen. After a handful, even the **correct** PIN lands
|
||||
there too — the TPM has entered lockout and is refusing everyone, which is the point.
|
||||
|
||||
**Failure means:** wrong PINs that never stop being accepted for the attempt is the `NoDA` attribute having
|
||||
been set on the sealed object, which would make a four-digit PIN guessable at full speed by anything holding
|
||||
the blob and the machine. `LinuxDeviceKeyStoreTests` asserts this against a software TPM; this is the same
|
||||
assertion against real firmware, where the lockout is real and recovery takes hours.
|
||||
|
||||
**Then put it back:** `sudo tpm2_dictionarylockout --clear-lockout`, or wait out the recovery time. Note
|
||||
which, because a user who trips this will ask — and the honest answer is that it needs an administrator.
|
||||
|
||||
### 19.8 An update is noticed, fetched, and installed by PackageKit and not by DodoSSH
|
||||
|
||||
With the nightly installed, wait for a newer one to be published — or press **CHECK NOW** on
|
||||
PREFERENCES → UPDATES once there is one.
|
||||
|
||||
**Pass:** the banner names the new version and downloads it. Pressing install raises the desktop's own
|
||||
*"Authentication is required to install software"* prompt, drawn by polkit and not by DodoSSH. Accepting it
|
||||
installs the package; declining it changes nothing and leaves the vault unlocked and every shell open.
|
||||
DodoSSH does **not** restart, and the version on the preferences screen does not change until you close and
|
||||
reopen it.
|
||||
|
||||
**Failure means:** an update that is offered and then refused by PackageKit is the version comparison
|
||||
disagreeing with rpm — `rpm -q --queryformat '%{VERSION}-%{RELEASE}' dodossh-nightly` against the
|
||||
`rpmVersion` in the release's `linux-nightly.json` is the comparison being made, and `RpmVersionTests`
|
||||
covers the algorithm. A message naming PackageKit means it is not installed; it is a weak dependency, so a
|
||||
minimal install legitimately lacks it. **The vault locking or shells closing when you decline is the
|
||||
serious one** — it means `ApplyingEndsTheProcess` has been set true, which punishes somebody for saying no.
|
||||
|
||||
### 19.9 A tampered package is refused before PackageKit ever sees it
|
||||
|
||||
Not reachable without editing the feed, so this is the one to reason about rather than perform unless you
|
||||
are changing that code: the manifest's `sha256` is checked after the download and the file is deleted if it
|
||||
does not match.
|
||||
|
||||
**Pass:** `~/.local/share/dodossh.nightly/updates/` holds a package only when its digest matched.
|
||||
|
||||
**Failure means:** a leftover `.rpm` there after a failed update is a file somebody could reasonably install
|
||||
by hand, named as though the application had endorsed it.
|
||||
|
||||
### 19.10 The nightly installs beside the release rather than over it
|
||||
|
||||
With the release package installed, install the nightly one too.
|
||||
|
||||
**Pass:** `dnf` installs it as a second package rather than as an upgrade. Both entries appear in the menu,
|
||||
named *DodoSSH* and *DodoSSH Nightly*. Both run at once. Signing into the nightly asks for the passphrase
|
||||
again and appears on the account as a separate device.
|
||||
|
||||
**Failure means:** `dnf` offering to *upgrade* rather than install is the two packages sharing a name. A
|
||||
nightly that opens the release's vault already unlocked is the profile separation gone — that comes from
|
||||
`DodoChannel=nightly` reaching `ClientPaths`, not from the packaging, so check
|
||||
`~/.local/share/dodossh.nightly` exists rather than looking at the spec.
|
||||
|
||||
### 19.11 Nothing in the installed tree is writable by anyone but root
|
||||
|
||||
```bash
|
||||
find /opt/DodoSSH ! -user root -o -perm -o+w
|
||||
```
|
||||
|
||||
**Pass:** no output.
|
||||
|
||||
**Failure means:** any line here is a local account being able to replace the SSH client every other account
|
||||
on the machine runs, which for an application holding a team's credentials in plaintext is the whole trust
|
||||
model walked around rather than attacked. `ci-linux-rpm.sh` refuses to produce such a package and the spec
|
||||
sets every mode absolutely, so a hit here means one of those two was changed — it cannot arrive by accident
|
||||
from a publish directory.
|
||||
|
||||
### 19.12 Removing it leaves the vault, and leaves the other channel alone
|
||||
|
||||
Register a device, sync something, then `sudo dnf remove dodossh`.
|
||||
|
||||
**Pass:** `~/.local/share/dodossh` still holds the cache and the outbox. `/opt/DodoSSH` is gone entirely.
|
||||
`/opt/DodoSSH.Nightly` and its menu entry are untouched.
|
||||
|
||||
**Failure means:** a missing cache is `ClientPaths` having been pointed somewhere under `/opt`, which would
|
||||
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
|
||||
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
|
||||
something wrote into the install directory at runtime and would survive a reinstall.
|
||||
@@ -388,6 +388,19 @@ agent of our own plus ProxyJump covers the real use cases.
|
||||
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
|
||||
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
|
||||
|
||||
**That suite has an intermittent `The connection was closed by the remote host`**, on whichever test
|
||||
connects first, within tens of milliseconds. Seen in CI and reproducible locally. *Mitigated, not
|
||||
solved:* `SshServerFixture` now raises sshd's `MaxStartups` from its compiled-in `10:30:100`, which
|
||||
refuses connections at random past ten unauthenticated ones in flight — reachable because xUnit runs
|
||||
test classes in parallel and most of them connect. The fixture comment carries the full argument and
|
||||
is explicit that the cure is unproven.
|
||||
|
||||
**And the reason it is unproven is a measurement trap worth not falling into twice.** Docker
|
||||
throughput on the Windows development machine swings enough to swamp the effect: the identical
|
||||
unmodified suite ran 85/85 clean and, an hour later, failed 13 runs out of 15. Any before/after flake
|
||||
comparison taken there is noise. Measure this class of thing in CI, or make the server say why —
|
||||
raise sshd's `LogLevel`, disable Ryuk so the container outlives the run, and read `docker logs`.
|
||||
|
||||
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
|
||||
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
|
||||
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
||||
@@ -699,6 +712,30 @@ The lasting hazard is the first paragraph and not the fix. Any change to a share
|
||||
to a lock file this repository cannot verify from a machine without the Android workload, and it will go
|
||||
on being noticed later than every other one.
|
||||
|
||||
**A lock file can go stale with nothing in this repository changing, because `Microsoft.NET.ILLink.Tasks`
|
||||
is versioned by the SDK and `global.json` lets the SDK float.** The reference is implicit — nothing in any
|
||||
`.csproj` asks for it — and its version tracks the runtime patch band, while `global.json` pins only
|
||||
`10.0.100` with `rollForward: latestMinor`. So `setup-dotnet` installs whatever the newest 10.x SDK is on
|
||||
the day, and the moment that SDK's band moves, locked-mode restore stops:
|
||||
|
||||
```
|
||||
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||
from [10.0.10, ) to [10.0.11, ).
|
||||
```
|
||||
|
||||
It named `DodoSSH.Client.Android`, `DodoSSH.Contracts` and `DodoSSH.Crypto` — the three lock files that
|
||||
carry the entry — on a commit that touched none of them and no dependency at all.
|
||||
|
||||
The fix is `--force-evaluate` on those three, **from a machine whose SDK is at least as new as the
|
||||
runner's**, which is the part that is easy to get wrong: a `--force-evaluate` from an older SDK rewrites
|
||||
the lock at the older version, changes nothing, and looks like it worked. Check `dotnet --version` against
|
||||
the version in the error before believing a regeneration.
|
||||
|
||||
This will recur on every SDK patch that moves the band. It is the accepted cost of letting the SDK float:
|
||||
the alternative is pinning an exact SDK in `global.json`, which trades a recurring lock-file bump for a
|
||||
recurring toolchain bump and makes every contributor install one specific SDK. Neither is free, and this
|
||||
repository has chosen the floating side deliberately.
|
||||
|
||||
**.NET for Android cannot be built on a musl host, and this project's runner is Alpine. Every message the
|
||||
toolchain produces on the way to saying so names a missing file that is present.** Three CI rounds went
|
||||
into this and the first two fixed symptoms, so the messages are worth reading in the order they arrive.
|
||||
|
||||
Executable
+385
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Turns a published linux-x64 tree into an RPM, and then checks the RPM rather than the exit code.
|
||||
#
|
||||
# Called from two jobs in .github/workflows/ci.yml and written once for the reason the four copies of
|
||||
# "ensure node and git" in that file are not: those cannot be factored out — a composite action would
|
||||
# need the checkout it exists to unblock — and this can. The build job packages the release-shaped
|
||||
# RPM and throws it away, exactly as it does for Windows and macOS; the desktop job packages the
|
||||
# nightly-shaped one and publishes it. Everything that differs between those two is an argument.
|
||||
#
|
||||
# It is also runnable at a desk, which is the point of it being a script:
|
||||
#
|
||||
# dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||
# --configuration Release --runtime linux-x64 --self-contained true \
|
||||
# -p:RestoreLockedMode=false --output /tmp/linux-x64
|
||||
# scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
|
||||
#
|
||||
# Nothing here holds a secret and nothing here uploads anything. Publishing is the calling job's, and
|
||||
# ADR 0013 rule 3 is what decides which channel may do it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
payload=""
|
||||
version=""
|
||||
channel="release"
|
||||
out=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--payload) payload="$2"; shift 2 ;;
|
||||
--version) version="$2"; shift 2 ;;
|
||||
--channel) channel="$2"; shift 2 ;;
|
||||
--out) out="$2"; shift 2 ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
for required in payload version out; do
|
||||
if [ -z "${!required}" ]; then
|
||||
echo "--$required is required." >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
case "$channel" in
|
||||
release|nightly) ;;
|
||||
*) echo "--channel must be release or nightly, not '$channel'." >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# The apphost, named rather than globbed. A publish that produced no apphost at all would otherwise
|
||||
# package happily into an RPM whose /usr/bin symlink points at nothing, and the first thing anybody
|
||||
# would learn about it is a shell saying "No such file or directory" for a file that is right there.
|
||||
if [ ! -s "$payload/DodoSSH" ]; then
|
||||
echo "There is no apphost at $payload/DodoSSH, so there is nothing to package." >&2
|
||||
ls -la "$payload" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ◆ THE FOUR THINGS THAT DIFFER BETWEEN THE CHANNELS, AND WHY IT IS FOUR AND NOT ONE.
|
||||
#
|
||||
# This is ADR 0013 rule 9's table, in the spelling this platform uses. The package name keeps dnf from
|
||||
# treating one as an upgrade of the other; the directory under /opt keeps the two trees apart on disk;
|
||||
# the /usr/bin name and the desktop entry keep them apart in front of a person, who would otherwise
|
||||
# have two identical launchers and no way to tell which was which.
|
||||
#
|
||||
# The fifth separation — the profile directory holding the vault cache, the outbox and the device key
|
||||
# — is not here and must not be, because it belongs to the build rather than to the package: the
|
||||
# nightly is published with DodoChannel=nightly, and ClientPaths reads that to suffix the directory.
|
||||
# Doing it here as well would be a second answer to a question that already has one.
|
||||
if [ "$channel" = nightly ]; then
|
||||
name=dodossh-nightly
|
||||
dir=DodoSSH.Nightly
|
||||
bin=dodossh-nightly
|
||||
title="DodoSSH Nightly"
|
||||
else
|
||||
name=dodossh
|
||||
dir=DodoSSH
|
||||
bin=dodossh
|
||||
title="DodoSSH"
|
||||
fi
|
||||
|
||||
# ◆ SEMVER IS NOT AN RPM VERSION, AND THE TRANSLATION IS THE ONE PLACE THIS CAN GO QUIETLY WRONG.
|
||||
#
|
||||
# rpm forbids `-` in a version, which is exactly the character SemVer uses to introduce a prerelease.
|
||||
# MinVer's answer on main is 0.0.1-alpha.0.143, so the naive move — strip the suffix — would give
|
||||
# every nightly the version 0.0.1 and make each one look like a reinstall of the last.
|
||||
#
|
||||
# `~` is rpm's own answer and has been since 4.10: it sorts *before* the empty string, so
|
||||
# 0.0.1~alpha.0.143 is older than 0.0.1 the way alpha.143 is older than the release. Heights keep
|
||||
# rising within a floored version and the first real tag moves past all of them, which is the same
|
||||
# ordering property the Velopack channels have and is worth having on both.
|
||||
#
|
||||
# Build metadata after `+` is dropped rather than translated. It is the one part of a SemVer string
|
||||
# that is defined as not participating in ordering, so keeping it would put a character rpm has no
|
||||
# rule for into the field ordering is done on.
|
||||
rpm_version="${version%%+*}"
|
||||
rpm_version="${rpm_version/-/\~}"
|
||||
|
||||
case "$rpm_version" in
|
||||
*-*)
|
||||
echo "The version $version translates to $rpm_version, which still contains a hyphen." >&2
|
||||
echo "rpm cannot order a version with one in it; this needs a rule rather than a retry." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Always 1. The release field distinguishes several packagings of one upstream version, and there is
|
||||
# only ever one packaging here — the version already carries the build height, because upstream and
|
||||
# packager are the same repository.
|
||||
rpm_release=1
|
||||
|
||||
echo "==> $name $rpm_version-$rpm_release.x86_64, from $payload"
|
||||
|
||||
# ◆ rpmbuild ON A MUSL HOST, WHICH IS FINE, AND WHY IT IS WORTH SAYING SO.
|
||||
#
|
||||
# The runner is Alpine. Nothing about that shows up in the package: rpmbuild composes an archive and
|
||||
# some metadata, and the one part of it that reads the payload — elfdeps, which the spec leaves on for
|
||||
# requires — parses ELF headers with libelf rather than loading anything, so it reads x86_64 glibc
|
||||
# binaries on a musl host correctly. Building in a Fedora container was the alternative and would
|
||||
# have meant `docker cp`-ing a 130 MB publish tree in and the package back out, because this runner is
|
||||
# itself a container and a bind mount resolves on the daemon's host rather than here — see the note on
|
||||
# the android job for the hour that cost.
|
||||
if ! command -v rpmbuild >/dev/null 2>&1; then
|
||||
SUDO=""
|
||||
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
||||
|
||||
echo "==> installing rpm"
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
$SUDO apk add --no-cache rpm
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
$SUDO dnf install -y rpm-build
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y --no-install-recommends rpm
|
||||
else
|
||||
echo "No apk, dnf or apt-get here, so rpmbuild cannot be installed from inside the job." >&2
|
||||
echo "Add rpm to the runner's image; the Linux package cannot be built without it." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rpmbuild --version
|
||||
|
||||
# ◆ THE LIST OF PRIVATE SONAMES, WORKED OUT FROM THE TREE RATHER THAN WRITTEN DOWN.
|
||||
#
|
||||
# build/linux/dodossh.spec turns rpm's automatic provides off for everything under /opt, because the
|
||||
# libraries there are this application's private copies and have no business satisfying anybody
|
||||
# else's dependency. The requires side has to be told the same thing about the same set, or a bundled
|
||||
# library that links another bundled library asks for a soname nothing provides and the package
|
||||
# becomes uninstallable on every host — which .NET's own libmscordbi.so, linking libmscordaccore.so,
|
||||
# does on every publish.
|
||||
#
|
||||
# Derived here because the set belongs to the payload. A list in the spec would be Microsoft's and
|
||||
# SkiaSharp's internal file names, maintained by hand, going stale on the first runtime bump — and
|
||||
# going stale in the direction that produces a package nobody can install.
|
||||
#
|
||||
# Only the top level is scanned, which is where a .NET publish puts its native libraries. The dots
|
||||
# are escaped because this becomes a regular expression; nothing else in a soname is a metacharacter.
|
||||
#
|
||||
# A shell glob rather than `find -printf`, which is GNU find's and not busybox's — the runner is
|
||||
# Alpine, where that flag is a usage message and an empty list, which would silently disable the whole
|
||||
# exclusion and put the uninstallable package back.
|
||||
bundled="$(
|
||||
for library in "$payload"/lib*.so*; do
|
||||
[ -f "$library" ] && basename "$library"
|
||||
done |
|
||||
sed 's/\./\\./g' |
|
||||
sort -u |
|
||||
paste -sd '|' -
|
||||
)"
|
||||
|
||||
if [ -z "$bundled" ]; then
|
||||
echo "There are no native libraries at the top of $payload." >&2
|
||||
echo "A self-contained .NET publish always has some, so this is a publish that went wrong" >&2
|
||||
echo "rather than a package with nothing to exclude." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
mkdir -p "$out"
|
||||
|
||||
# --target rather than trusting the host's arch, because the host's arch here is whatever the runner
|
||||
# happens to be and the payload's is decided by the RID the publish asked for. They agree today and
|
||||
# an aarch64 runner would silently make them disagree.
|
||||
#
|
||||
# `dist` is emptied deliberately. It is the %{?dist} tag that puts .fc42 in a filename, and it names
|
||||
# the distribution the package was *built* on — which for a self-contained tree built on Alpine would
|
||||
# be both meaningless and, if rpm guessed something plausible, actively misleading.
|
||||
rpmbuild -bb "$repo_root/build/linux/dodossh.spec" \
|
||||
--target x86_64 \
|
||||
--define "_topdir $work" \
|
||||
--define "_rpmdir $out" \
|
||||
--define "dist %{nil}" \
|
||||
--define "dodo_name $name" \
|
||||
--define "dodo_version $rpm_version" \
|
||||
--define "dodo_release $rpm_release" \
|
||||
--define "dodo_dir $dir" \
|
||||
--define "dodo_bin $bin" \
|
||||
--define "dodo_title $title" \
|
||||
--define "dodo_payload $payload" \
|
||||
--define "dodo_bundled $bundled" \
|
||||
--define "dodo_icons $repo_root/src/DodoSSH.Client.App/Assets" \
|
||||
--define "dodo_udev $repo_root/build/linux/71-dodossh-tpm.rules" \
|
||||
--define "dodo_licence $repo_root/LICENSE"
|
||||
|
||||
# rpmbuild writes into <_rpmdir>/<arch>/, and every caller wants the file rather than the tree.
|
||||
rpm_file="$out/x86_64/$name-$rpm_version-$rpm_release.x86_64.rpm"
|
||||
if [ ! -s "$rpm_file" ]; then
|
||||
echo "rpmbuild reported success and there is no package at $rpm_file." >&2
|
||||
find "$out" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
mv "$rpm_file" "$out/"
|
||||
rmdir "$out/x86_64" 2>/dev/null || true
|
||||
rpm_file="$out/$(basename "$rpm_file")"
|
||||
|
||||
echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
|
||||
|
||||
# ◆ AND NOW THE PART THAT IS NOT PACKAGING: ASKING THE PACKAGE WHAT IT SAYS ABOUT ITSELF.
|
||||
#
|
||||
# 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")"
|
||||
files="$(rpm -qp --list "$rpm_file")"
|
||||
|
||||
# 1. The bundled-soname trap the spec's provides exclusion sets. If some library under /opt ever
|
||||
# gains a DT_NEEDED on another library under /opt, elfdeps will put that soname in the requires
|
||||
# while the exclusion keeps it out of the provides, and the result is a package no host on earth
|
||||
# can satisfy. Nothing about it looks wrong until an install fails, so it is checked here against
|
||||
# the actual contents rather than assumed.
|
||||
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 -F "$soname(" >/dev/null; then
|
||||
unsatisfiable="$unsatisfiable $soname"
|
||||
fi
|
||||
done
|
||||
if [ -n "$unsatisfiable" ]; then
|
||||
echo "This package requires sonames it also carries and does not provide:$unsatisfiable" >&2
|
||||
echo "That is unsatisfiable on every host, and it means the requires exclusion computed above" >&2
|
||||
echo "no longer matches what rpm generated — a bundled library outside the top level, or an rpm" >&2
|
||||
echo "that has stopped honouring __requires_exclude. See build/linux/dodossh.spec." >&2
|
||||
exit 1
|
||||
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 -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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. elfdeps having actually run. If it silently did nothing — a runner image whose rpm was built
|
||||
# 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 '^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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. The four files a person interacts with, named individually. The tree under /opt is thousands of
|
||||
# entries and its absence would be caught by size alone; these are the small ones whose absence
|
||||
# would leave a package that installs, occupies 130 MB and puts nothing on the menu.
|
||||
for expected in \
|
||||
"/opt/$dir/DodoSSH" \
|
||||
"/usr/bin/$bin" \
|
||||
"/usr/share/applications/$bin.desktop" \
|
||||
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
||||
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
||||
do
|
||||
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" | awk 'NR<=40' >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 5. Nothing world-writable, which on this package is a security property rather than a lint rule.
|
||||
# The spec sets every mode explicitly and says why; this is the check that it worked, because the
|
||||
# failure it guards against arrives by inheritance from a publish directory rather than by anybody
|
||||
# changing this. A world-writable /opt/DodoSSH lets any local account replace the SSH client every
|
||||
# other account on the machine runs.
|
||||
#
|
||||
# Symlinks are excluded because they are always 777 on Linux and the mode means nothing; what the
|
||||
# link points at is what has permissions.
|
||||
writable="$(
|
||||
rpm -qp --queryformat '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm_file" |
|
||||
grep -v '^l' |
|
||||
awk '$1 ~ /^.{8}w/ { print }'
|
||||
)"
|
||||
if [ -n "$writable" ]; then
|
||||
echo "This package contains world-writable files:" >&2
|
||||
printf '%s\n' "$writable" >&2
|
||||
echo "Any local account could replace them. See the mode block in build/linux/dodossh.spec." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> requires:"
|
||||
printf '%s\n' "$requires" | sed 's/^/ /'
|
||||
|
||||
echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so."
|
||||
|
||||
# ◆ THE MANIFEST, WHICH IS WHAT AN INSTALLED CLIENT READS AND NOT WHAT A PERSON READS.
|
||||
#
|
||||
# The same arrangement the android job publishes, for the same reason its comment gives: a release's own
|
||||
# JSON is a large document that changes shape when the forge is upgraded, and the client fetches this on a
|
||||
# six-hourly timer. A small file this repository writes is a contract; a large one the forge writes is not.
|
||||
#
|
||||
# ◆ THE VERSION IN HERE IS THE RPM VERSION, AND THAT IS THE WHOLE POINT OF THE FILE.
|
||||
#
|
||||
# LinuxUpdateChannel compares with rpmvercmp, because rpm is what will accept or refuse the install — the
|
||||
# same argument AndroidChannelManifest makes for versionCode. Publishing the SemVer instead would mean the
|
||||
# client comparing one thing and the package manager another, and they disagree exactly where it matters:
|
||||
# 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are the same build, and only the second one sorts below 0.0.1.
|
||||
#
|
||||
# The SemVer goes in too and is used for nothing but the banner. A person asked which nightly they are on
|
||||
# should be able to answer with the string the preferences screen prints, and that screen reads the
|
||||
# assembly.
|
||||
#
|
||||
# ◆ AND THE DIGEST, WHICH THE OTHER TWO CHANNELS HAVE NO NEED OF.
|
||||
#
|
||||
# Velopack verifies what it downloads and Android refuses a package whose signature does not match the
|
||||
# installed one. Nothing about an RPM fetched over HTTPS and handed to PackageKit checks that it is the
|
||||
# file this release published — TLS authenticates the forge and says nothing about the bytes surviving a
|
||||
# proxy, a cache or a partial write. So the manifest names the digest and the client refuses anything else.
|
||||
#
|
||||
# It is not a signature and must not be read as one: it is written by the same job that writes the package,
|
||||
# so it proves integrity and not provenance. Provenance here is that the address is a constant pointing at
|
||||
# the project's own forge — ADR 0013 rule 4 — and a signed repository is what would improve on it.
|
||||
manifest="$out/linux-$channel.json"
|
||||
digest="$(sha256sum "$rpm_file" | cut -d' ' -f1)"
|
||||
|
||||
cat > "$manifest" <<JSON
|
||||
{
|
||||
"rpmVersion": "$rpm_version-$rpm_release",
|
||||
"version": "$version",
|
||||
"rpm": "$(basename "$rpm_file")",
|
||||
"sha256": "$digest"
|
||||
}
|
||||
JSON
|
||||
|
||||
echo "==> $(basename "$manifest"):"
|
||||
sed 's/^/ /' "$manifest"
|
||||
|
||||
# The lines the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, so the
|
||||
# desk use above still tells you where the file went.
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
||||
{
|
||||
echo "rpm=$rpm_file"
|
||||
echo "rpmName=$(basename "$rpm_file")"
|
||||
echo "manifest=$manifest"
|
||||
echo "manifestName=$(basename "$manifest")"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "$rpm_file"
|
||||
@@ -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.
|
||||
@@ -436,6 +602,31 @@
|
||||
<Setter Property="Fill" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Amber, and it does not contradict the remark above. That one says green is a fact about a host rather
|
||||
than an accent, and this is the colour for a fact that is not settled yet: green is what is true, purple
|
||||
is what you can press, and a connection still being made is neither. The palette's own rule gives amber
|
||||
to the caveat worth reading, which is exactly what this is.
|
||||
|
||||
Only the tab strips use it, and only for a tab with no shell behind it yet — the same amber, from the
|
||||
same brush, as the track and the running step on the connecting screen, so that a tab and the screen it
|
||||
opens agree about what is happening. See TerminalScreen.axaml.
|
||||
-->
|
||||
<Style Selector="Ellipse.dot.connecting">
|
||||
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Red for a shell whose connection broke, splitting the grey that used to cover every dead tab. A shell
|
||||
somebody exited and a shell that was cut off are the two dead states with the least in common, and on a
|
||||
phone the difference matters more than it does on a desk: a mobile link drops on its own, and the tab
|
||||
that went quiet while the screen was off is the one worth dialling again. See
|
||||
TerminalTabViewModel.IsDropped.
|
||||
-->
|
||||
<Style Selector="Ellipse.dot.dropped">
|
||||
<Setter Property="Fill" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
||||
<Style Selector="TextBlock.mono">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
@@ -448,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}" />
|
||||
|
||||
@@ -48,7 +48,12 @@
|
||||
the refusal has no continue button here either.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
|
||||
<!--
|
||||
Nine rows since the disconnect strip arrived; it is row 7, between the listing and the action bar, so
|
||||
that what it says is next to the buttons it replaces the point of. Auto and empty except while the
|
||||
session has been lost, so an ordinary browse pays nothing for it.
|
||||
-->
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto,Auto">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
||||
@@ -78,7 +83,13 @@
|
||||
</Grid>
|
||||
|
||||
<!-- ============ opening one ============ -->
|
||||
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding !IsConnected}">
|
||||
<!--
|
||||
ShowsRemoteInvitation rather than !IsConnected, which stopped being the same question when a
|
||||
connection could be lost rather than only closed: a session that went away leaves its listing on
|
||||
screen, and this card springing back over it would hide the one thing the strip below is offering to
|
||||
put the user back into. See TransfersViewModel.ShowsRemoteContents.
|
||||
-->
|
||||
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding ShowsRemoteInvitation}">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
|
||||
@@ -174,9 +185,48 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ ◆ typing where to go ============ -->
|
||||
<!--
|
||||
The breadcrumb below reaches only what is under the directory already listed, so crossing a host's
|
||||
filesystem was a tap and a round trip a level and a path somebody already knew could not be entered at
|
||||
all. This is the way in for one that is: absolute, relative to the directory showing, or starting at
|
||||
~. See SftpPath.Resolve.
|
||||
|
||||
GO is a real button rather than only the keyboard's own action key, and that is a phone decision: a
|
||||
software keyboard's return key is whatever the IME decided to draw, this application does not set the
|
||||
IME action, and a path box whose only commit was a key that may say "newline" would be a box nothing
|
||||
could be submitted from. The Enter binding stays for the hardware keyboards this head does meet.
|
||||
-->
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,8,Auto" Margin="14,0,14,6"
|
||||
IsVisible="{Binding ShowsRemoteContents}">
|
||||
<TextBox Grid.Column="0" Classes="field" Text="{Binding RemotePathDraft}"
|
||||
PlaceholderText="/path/to/somewhere">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding GoToTypedRemotePathCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Padding="16,0" Content="GO"
|
||||
Command="{Binding GoToTypedRemotePathCommand}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ How many are gathered up, and the one gesture that gathers or releases them all. Only while
|
||||
something is — an always-present row would spend 30 pixels of a listing on a count of zero.
|
||||
-->
|
||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,*" Margin="14,0,14,6" VerticalAlignment="Bottom"
|
||||
IsVisible="{Binding HasCheckedRemote}">
|
||||
<Button Grid.Column="0" Classes="secondary" MinHeight="36" Padding="12,0"
|
||||
Command="{Binding ToggleAllRemoteCommand}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="ALL / NONE" />
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="12" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center" Foreground="{StaticResource AccentText}"
|
||||
Text="{Binding CheckedRemoteLabel}" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ where we are ============ -->
|
||||
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
|
||||
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
||||
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
|
||||
Command="{Binding RemoteUpCommand}">
|
||||
@@ -215,7 +265,7 @@
|
||||
answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as
|
||||
"this host has nothing pinned".
|
||||
-->
|
||||
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
<ScrollViewer Grid.Row="5" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
|
||||
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -240,9 +290,9 @@
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ the listing ============ -->
|
||||
<Panel Grid.Row="5">
|
||||
<Panel Grid.Row="6">
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
||||
<TextBlock Classes="body" IsVisible="{Binding ShowsRemoteInvitation}" Margin="24,12"
|
||||
VerticalAlignment="Top" Text="{Binding Status}" />
|
||||
|
||||
<!--
|
||||
@@ -259,7 +309,7 @@
|
||||
selection navigate would send a single desktop click walking into a directory.
|
||||
-->
|
||||
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
|
||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding IsConnected}"
|
||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding ShowsRemoteContents}"
|
||||
Tapped="OnRemoteEntryTapped">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
@@ -276,15 +326,27 @@
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Border MinHeight="52" Padding="10,7">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
|
||||
<!--
|
||||
◆ The tick, which on this head is the only multi-selection there can be: a phone has no
|
||||
ctrl-click and no shift-click. It is not the list's selection — that stays "the row I am
|
||||
looking at", which RENAME, MOVE and PERMS act on one at a time — and this is "the rows I
|
||||
have gathered up", which SAVE and DELETE take. See RemoteEntryRowViewModel.IsChecked.
|
||||
|
||||
Its own 40-pixel column so a thumb aimed at it does not open the directory beside it: the
|
||||
row's own tap navigates, which is this head's one behaviour the desktop does not share.
|
||||
-->
|
||||
<CheckBox Grid.Column="0" Width="40" MinWidth="0" VerticalAlignment="Center"
|
||||
IsChecked="{Binding IsChecked}" Click="OnRemoteRowChecked" />
|
||||
|
||||
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="▸"
|
||||
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
|
||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
||||
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="·"
|
||||
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||
<StackPanel Grid.Column="2" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
@@ -295,7 +357,7 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Modified}"
|
||||
<TextBlock Grid.Column="3" Classes="detail" Text="{Binding Modified}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -304,9 +366,50 @@
|
||||
</ListBox>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ what the screen says once the session has stopped ============ -->
|
||||
<!--
|
||||
The phone's half of the desktop's SessionEndedBar, and it binds this screen's own view model rather
|
||||
than the shell's — this control's DataContext is TransfersViewModel, where the desktop's strip sits in
|
||||
MainWindow beside a status bar and can ask which surface it is on. The facts are the same two:
|
||||
TransfersViewModel.LostConnection and the reconnect that goes back to the directory showing.
|
||||
|
||||
A phone needs it more than a desk does, and for the reason its terminal does: a mobile link drops on
|
||||
its own and the screen goes off, so this session is discovered later with a listing on it and no
|
||||
account of why nothing works. The listing stays deliberately — see ShowsRemoteContents.
|
||||
-->
|
||||
<Border Grid.Row="7" IsVisible="{Binding IsConnectionLost}" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Danger}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||
<StackPanel Spacing="8">
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot" Classes.dropped="True" Width="7" Height="7" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="label" FontSize="9" Text="DISCONNECTED" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Two lines maximum, for the reason the terminal's strip caps its own: a transport error can hand
|
||||
over whatever the operating system called it, and a strip that grew to fit one would eat the
|
||||
listing above it on a screen that has none to spare.
|
||||
-->
|
||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding LostConnection}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding ReconnectCommand}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
|
||||
</Button>
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding DisconnectCommand}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ what to do with the chosen entry ============ -->
|
||||
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
|
||||
<Border Grid.Row="6" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
||||
<Border Grid.Row="8" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
@@ -328,6 +431,105 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ Renaming and moving, in the place the deletion question already occupies and never at the same
|
||||
time as it. One form for both — see RemoteMoveRequest for why the two questions share a shape and
|
||||
differ only in their words — and no danger colours on it, because a rename is undone by renaming
|
||||
back and the deletion above is the one thing here that nothing can walk back.
|
||||
-->
|
||||
<Border IsVisible="{Binding IsMovingRemote}" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||
Padding="13,11">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteMove.Question}" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" Text="{Binding PendingRemoteMove.FullPath}" />
|
||||
<TextBox Classes="field" Text="{Binding RemoteMoveTarget}"
|
||||
PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConfirmMoveRemoteCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBlock Classes="body" Text="{Binding PendingRemoteMove.Note}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44"
|
||||
Content="{Binding PendingRemoteMove.ConfirmLabel}"
|
||||
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveRemoteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ The mode, as nine boxes and the number they add up to — in the place the other two questions
|
||||
already occupy and never at the same time as either. See RemotePermissionsRequest for why it is
|
||||
boxes rather than a field to type 755 into, and TransfersScreen.axaml for the desktop's own copy
|
||||
of the same form.
|
||||
|
||||
Compact rather than a second layout: the three columns are the mode's own three triples, and at
|
||||
360dp they are exactly what fits. The octal is printed underneath as it changes, which is what
|
||||
lets somebody who came here to type chmod check the boxes they just ticked.
|
||||
-->
|
||||
<Border IsVisible="{Binding IsChangingRemotePermissions}" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||
Padding="13,11">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemotePermissions.Question}" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemotePermissions.FullPath}" />
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,44,44,44" Margin="0,2">
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Classes="detail" Text="read"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" Classes="detail" Text="write"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="3" Classes="detail"
|
||||
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
|
||||
HorizontalAlignment="Center" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="12" Text="owner"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="12" Text="group"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
|
||||
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
|
||||
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="12" Text="others"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
|
||||
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
|
||||
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource AccentText}"
|
||||
Text="{Binding PendingRemotePermissions.Preview}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="APPLY"
|
||||
Command="{Binding ConfirmRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelRemotePermissionsCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
|
||||
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
|
||||
@@ -349,7 +551,7 @@
|
||||
Foreground="{StaticResource Accent}"
|
||||
Background="{StaticResource Raised}" />
|
||||
<TextBlock Classes="detail" Text="{Binding Progress}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -386,20 +588,44 @@
|
||||
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
||||
a file is selected and something is connected — and is reused rather than restated here.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
||||
IsEnabled="{Binding CanDownload}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
<!--
|
||||
◆ The two that put a file right rather than taking it away, above the row DELETE is on and away
|
||||
from it. Both open a form in place of these buttons — see the card above — because a phone has
|
||||
nowhere to put a text field except where something else already was.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="RENAME"
|
||||
Command="{Binding RenameRemoteCommand}" IsEnabled="{Binding CanRenameRemote}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
||||
Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
||||
<!--
|
||||
◆ PERMS beside DELETE rather than on the row above, which is full: it is the fourth thing that can
|
||||
be done to a chosen row and the third that puts one right. Dark on a bucket, where an object has an
|
||||
access policy rather than nine bits — see CanChangeRemotePermissions.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="PERMS"
|
||||
Command="{Binding ChangeRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding CanChangeRemotePermissions}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CLOSE"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
<Panel Grid.Column="2" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
|
||||
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
||||
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||
|
||||
@@ -35,6 +35,15 @@ internal sealed partial class FilesScreen : UserControl
|
||||
/// read its size the same gesture as fetching it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>Tells the screen a row's tick moved, so the count above the listing keeps up.</summary>
|
||||
/// <remarks>
|
||||
/// The desktop's own handler, for the reason given there: the count is a property of the collection
|
||||
/// rather than of any row, and each listing replaces every row it has. See
|
||||
/// <c>TransfersViewModel.RemoteChecksChanged</c>.
|
||||
/// </remarks>
|
||||
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) =>
|
||||
(DataContext as TransfersViewModel)?.RemoteChecksChanged();
|
||||
|
||||
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
|
||||
|
||||
@@ -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>
|
||||
@@ -1029,6 +1029,48 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
|
||||
moment one is wanted is while deciding how a host authenticates, and this head has no keychain
|
||||
editor for credentials at all — so without this a phone could bind a host to a credential but
|
||||
never make one. Writes to the keychain the instant ADD is pressed, exactly as the new-tag box
|
||||
below does and for the same reason: a host can only name an id that exists.
|
||||
-->
|
||||
<Button Classes="secondary" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
|
||||
MinHeight="40" Padding="14,0"
|
||||
IsVisible="{Binding !IsAddingEditorCredential}"
|
||||
Command="{Binding BeginEditorCredentialCommand}" />
|
||||
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
|
||||
IsVisible="{Binding IsAddingEditorCredential}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="NEW CREDENTIAL" />
|
||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialLabel}"
|
||||
PlaceholderText="name" />
|
||||
<!--
|
||||
Optional, and what makes a credential its own item: one account on twenty machines is
|
||||
rotated in one place. Left blank, this host's own username is used.
|
||||
-->
|
||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialUsername}"
|
||||
PlaceholderText="username (blank: this host's own)" />
|
||||
<TextBox Classes="field secret" Text="{Binding EditorNewCredentialPassword}"
|
||||
PlaceholderText="password" />
|
||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialNotes}"
|
||||
PlaceholderText="notes" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||
Text="Added to the keychain as soon as you press ADD, so it stays even if you leave this host without saving." />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Content="ADD" MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding AddEditorCredentialCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Content="CANCEL" MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding CancelEditorCredentialCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than
|
||||
one vault that can be written to, exactly as on the desktop — an existing host's vault is not a
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -320,8 +320,14 @@
|
||||
which was true when a tab could not exist without a session; one can now —
|
||||
connecting opens the tab first — and a dot that was green before anything had
|
||||
answered would be the one thing on this strip claiming something untrue.
|
||||
|
||||
Amber while it is being made, which is the other half of that correction. Not being
|
||||
green stopped the dot lying, but it left a tab still dialling drawn exactly like a
|
||||
tab whose shell has exited — the two states on this strip with the least in common,
|
||||
one worth waiting for and one over. See Phone.axaml.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,94 @@
|
||||
gesture does the same thing the arrow does.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
|
||||
<!--
|
||||
── the connecting step list ─────────────────────────────────────────────────────────────────────
|
||||
The same five rows the desktop's ConnectingCard draws, from the same reported phases, in this head's
|
||||
own sizes. Kept here rather than in Phone.axaml because nothing else on this head has a step list —
|
||||
the theme file is for what more than one screen shares, and a rule that exists for one control is
|
||||
easier to read beside it.
|
||||
|
||||
Amber for the step in flight, green behind it, red where it stopped. That is the palette's rule
|
||||
rather than an exception to it: green is what is true and purple is what you can press, and a step
|
||||
still happening is neither. See ConnectingCard.axaml for the longer version of this argument, and
|
||||
Palette.axaml for the rule itself.
|
||||
|
||||
A phone needs this more than a desktop does, which is the same thing the connecting block below
|
||||
already says about itself: mobile links are slower and drop more often, so the stretch this describes
|
||||
is longer here and more likely to end badly.
|
||||
-->
|
||||
<!--
|
||||
Its own FontFamily rather than the row also carrying the mono class, which is this head's convention
|
||||
and not a stylistic preference: Phone.axaml's mono sets a colour and a size along with the family, so
|
||||
a caption wearing both classes would be asking two rules for one Foreground and settling it on style
|
||||
ordering. Every other text class here — body, label, title, detail — names its own family for exactly
|
||||
that reason. The desktop's mono sets the family alone, which is why ConnectingCard composes the two
|
||||
and this does not.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepcaption">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||
</Style>
|
||||
|
||||
<!-- Fixed width and centred: four different characters on a ragged edge is a list that looks broken. -->
|
||||
<Style Selector="TextBlock.stepmark">
|
||||
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Width" Value="13" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
4 rather than the desktop's 5, which is the only deliberate difference between the two heads here:
|
||||
this bar sits in a column 24 from each edge of a 360dp screen rather than under a 460-wide card, so
|
||||
the same height reads as a heavier rule across a narrower span.
|
||||
-->
|
||||
<Style Selector="ProgressBar.steptrack">
|
||||
<Setter Property="Height" Value="4" />
|
||||
<Setter Property="MinHeight" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="2" />
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="ProgressBar.steptrack.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
</UserControl.Styles>
|
||||
|
||||
<Panel>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!--
|
||||
Four rows since the disconnect strip arrived. It is Auto and empty except while the selected tab's shell
|
||||
has stopped, so an ordinary session pays nothing for it — and when it is up it takes its height off the
|
||||
renderer rather than drawing over it, which on this head is not optional either: the renderer is a native
|
||||
child view composited above everything Avalonia draws, so a banner over its rectangle would be invisible.
|
||||
-->
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!-- ============ the bar ============ -->
|
||||
<!--
|
||||
@@ -97,8 +182,14 @@
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
<!--
|
||||
Green only while there is a shell behind it, amber while one is being made, and
|
||||
red where a connection broke rather than a shell exiting; see the same dot in
|
||||
PhoneShell, and Phone.axaml for why neither of the last two is a rule broken.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}"
|
||||
Classes.dropped="{Binding IsDropped}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||
Text="{Binding Label}" />
|
||||
@@ -250,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}" />
|
||||
@@ -284,11 +375,82 @@
|
||||
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
||||
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Where a single unchanging "connecting…" used to be. The track counts steps that really finished
|
||||
against the five there are — StepsDone over StepCount, never a percentage, because the arithmetic
|
||||
that makes a percentage is the arithmetic that starts inventing one. See TerminalTabViewModel.
|
||||
|
||||
Drawn for both states rather than once per state: a refused connection has the same five rows and
|
||||
the same track, and the only differences are that one row is red and the track stops where it got
|
||||
to. Two templates kept identical for the sake of a colour is how the two drift apart.
|
||||
-->
|
||||
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="6" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9">
|
||||
<TextBlock Classes="stepmark"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Mark}" />
|
||||
<TextBlock Classes="stepcaption"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Caption}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
Only for a refusal now. While a connection is being made this used to be the whole of what this
|
||||
screen said, and it is now the step list's running row said twice — so it is shown for the one
|
||||
state the list cannot put into words: why it stopped.
|
||||
-->
|
||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}"
|
||||
Foreground="{StaticResource Danger}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
|
||||
<!--
|
||||
Two 44-high targets side by side rather than one, and the second is the logs: the step list is
|
||||
this attempt and the log is every other one, which is the question a connection that is taking too
|
||||
long on a mobile link actually raises — has this machine ever worked from here. Reached the
|
||||
ordinary way, through ShowScreenCommand, exactly as the rail and MORE reach it.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Left">
|
||||
<!--
|
||||
Only for a refusal, and it runs the same command the disconnect strip below the renderer does —
|
||||
the vault's own connect path, back into this same tab. Worth more on a phone than on a desk: the
|
||||
commonest refusal here is a mobile link that was not up yet, and the alternative is going back
|
||||
to the hosts screen to find a machine whose name is already on this card.
|
||||
-->
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="TRY AGAIN" />
|
||||
</Button>
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||
</Button>
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="SHOW LOGS" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -309,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 ============ -->
|
||||
@@ -320,70 +505,164 @@
|
||||
-->
|
||||
<!-- 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.
|
||||
-->
|
||||
<Border Grid.Row="2" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||
<!--
|
||||
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
|
||||
its own, the screen goes off, and the tab that went quiet is discovered later with no idea whether the
|
||||
shell was exited or cut off. So it names which, prints the sentence that came with it, and offers the
|
||||
one useful answer.
|
||||
|
||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
|
||||
VerticalAlignment="Center" />
|
||||
</ScrollViewer>
|
||||
Two lines rather than the desktop's one, because 360dp cannot hold a word, a sentence and two buttons
|
||||
across. The word and the buttons share the top line — the buttons are where a thumb already is — and
|
||||
the sentence wraps underneath at two lines maximum, which is enough for anything sshd or a socket
|
||||
error actually says and short of a strip that eats the terminal.
|
||||
|
||||
RECONNECT is disabled rather than hidden when there is nothing to dial: a machine typed in by hand has
|
||||
no keychain entry to redial, and a control visibly refusing answers "why not" better than an absent one.
|
||||
See MainWindowViewModel.CanReconnectSelectedTab.
|
||||
-->
|
||||
<Border Grid.Row="2" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="16,8"
|
||||
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
|
||||
<StackPanel Spacing="6">
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot" Classes.dropped="{Binding IsSessionDropped}" Width="7" Height="7"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="label" FontSize="9" Text="{Binding SessionEndedText}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="row" MinHeight="40" Padding="12,0"
|
||||
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
|
||||
</Button>
|
||||
<Button Classes="row" MinHeight="40" Padding="12,0"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding SessionEndedDetail}"
|
||||
IsVisible="{Binding SessionEndedDetail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<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
|
||||
|
||||
@@ -74,9 +74,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
@@ -984,7 +984,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -1044,9 +1044,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -1140,11 +1140,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,11 +475,34 @@
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The user popover itself: the panel the rail's chip opens, in this window's own idiom rather than the
|
||||
theme's. The shared MenuFlyoutPresenter rule further down this file gives every popup Chrome and a
|
||||
4-pixel radius, which is right for a context menu and wrong for this one — the design draws the account
|
||||
menu as a rounded card, the same radius-12 shape as every other floating surface here. Reached with
|
||||
FlyoutPresenterClasses from NavRail.axaml rather than by widening that rule, so a right-click menu two
|
||||
screens away does not quietly become a card as well.
|
||||
-->
|
||||
<Style Selector="FlyoutPresenter.poppanel">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="8" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A row inside the user popover: a vault switch, "New vault", Settings, Preferences, Vaults, Logout. All
|
||||
six share one shape — flat, a track fill under the pointer, 8 pixels of rounding — because the popover
|
||||
draws them as one list and a row that looked different from its neighbours would read as a separator
|
||||
that is not one.
|
||||
|
||||
◆ FLAT MEANS SAYING SO, which this rule did not. It set a corner radius and a padding and left the
|
||||
Background alone, so every row wore the Fluent theme's own button fill and its border: six raised pills
|
||||
stacked in a menu, where the design draws six lines of text that light up under the pointer. The hover
|
||||
rule below was already right and was simply invisible against a fill that was there all along. Set on
|
||||
the ContentPresenter as well as on the Button, the same as Button.flat does and for the same reason —
|
||||
the theme's template binds its own brush there, and a Background set only on the control loses to it.
|
||||
-->
|
||||
<Style Selector="Button.poprow">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
@@ -488,14 +511,20 @@
|
||||
<Setter Property="Padding" Value="11,4" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="MinHeight" Value="20" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.poprow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
<Style Selector="Button.poprow:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The check square beside a shown vault in the popover — magenta rather than the accent, because the
|
||||
@@ -594,10 +623,13 @@
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5b: THE HOST HEADER'S "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ───────────────────────────────
|
||||
── v5b: THE "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ─────────────────────────────────────────────
|
||||
A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c>
|
||||
rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it
|
||||
no filled hover, only the border turning to the accent. See <c>SessionHeader.axaml</c>.
|
||||
no filled hover, only the border turning to the accent.
|
||||
|
||||
Named for the host header it was drawn for, which v5c-4 retired; the button itself moved intact to the
|
||||
head of the session sidebar and is stretched across that column there. See <c>SessionSidebar.axaml</c>.
|
||||
-->
|
||||
<Style Selector="Button.headerghost">
|
||||
<Setter Property="Height" Value="32" />
|
||||
@@ -643,6 +675,30 @@
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5c-4: THE SIDEBAR'S OWN CLOSE AND REOPEN ────────────────────────────────────────────────────────
|
||||
One class for both, because they are one control in two states — a 26-pixel square carrying a chevron,
|
||||
at the head of the column when it is open and at the head of the rail when it is not. Square rather
|
||||
than the 33-tall rows below it: it is chrome belonging to the panel, not an entry in the list the panel
|
||||
is holding, and matching the rows' shape would have offered it as one.
|
||||
-->
|
||||
<Style Selector="Button.sidebargrip">
|
||||
<Setter Property="Width" Value="26" />
|
||||
<Setter Property="Height" Value="26" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.sidebargrip /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.sidebargrip:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The "+ Pin folder" / "+ Add Snip" row at the foot of each section: 30 tall rather than 33, and its own
|
||||
quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections.
|
||||
@@ -772,6 +828,41 @@
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ THE FILE PANE'S PATH BOX, which used to be a TextBlock.
|
||||
|
||||
The SFTP pane's header printed where it was and gave no way to say where to go: reaching a directory
|
||||
the breadcrumb could not see meant walking to a common ancestor and back down, one round trip a level,
|
||||
and a path somebody already knew could not be typed at all. It is a real TextBox now, and this style
|
||||
is the whole of what keeps it looking like the label it replaced — no fill and no border until it is
|
||||
pointed at, the same mono weight and colour the TextBlock carried, and the same height, so a header
|
||||
that gained an input did not gain a control that shouts about being one.
|
||||
|
||||
Declared after the base TextBox rules and after .address for the reason this file keeps restating:
|
||||
Avalonia has no specificity, so an exception written before the rules it overrides does nothing.
|
||||
-->
|
||||
<Style Selector="TextBox.pathbox">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="13.5" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="MinHeight" Value="26" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.pathbox /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.pathbox:pointerover /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.pathbox:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
||||
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
||||
@@ -857,18 +948,30 @@
|
||||
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
|
||||
rather than narrower.
|
||||
|
||||
◆ 224 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
||||
column at the window's minimum is 1016 less the rail's 190 and the drawer's 320, which is 506. The
|
||||
scrolling stack inside it takes 16 of margin on each side, and the vertical scrollbar takes its own —
|
||||
call the usable width 474. A WrapPanel fits floor(474 / (Width + 10)) per row, so two columns needs
|
||||
Width no more than 227.
|
||||
◆ 214 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
||||
column at the window's minimum is 1081 less the rail's 255 and the drawer's 320, which is 506. The
|
||||
scrolling stack inside it takes 26 of margin on each side, and the vertical scrollbar takes its own —
|
||||
call the usable width 454. A WrapPanel fits floor(454 / (Width + 10)) per row, so two columns needs
|
||||
Width no more than 217, and 214 is that with the same few pixels of slack the previous number kept.
|
||||
|
||||
The first number here was 248, from the same reasoning with the two margins left out. It laid out
|
||||
cleanly and the layout harness passed it, because the harness asks whether a control is inside the
|
||||
window and not how many of them fit on a line — so the grid quietly became one column wide at exactly
|
||||
the size this application guarantees, which is the shape the cards exist to avoid. The second was 232,
|
||||
derived the same way against the drawer's own 304; v5 widened the drawer to 320 for the ADDRESS field's
|
||||
breathing room, which narrowed the budget this number is drawn from and had to move it down in step.
|
||||
Every number here has moved at least once, and always because something beside the cards did:
|
||||
|
||||
· 248, from this reasoning with the two margins left out. It laid out cleanly and the layout harness
|
||||
passed it, because the harness asks whether a control is inside the window and not how many of them
|
||||
fit on a line — so the grid quietly became one column wide at exactly the size this application
|
||||
guarantees, which is the shape the cards exist to avoid.
|
||||
· 232, derived against the drawer's own 304, which v5 widened to 320 for the ADDRESS field's breathing
|
||||
room — narrowing the budget this number is drawn from and moving it down in step.
|
||||
· 224, which is what that gave. The stated arithmetic still said 1016 and 190 by then: v5b's rail took
|
||||
190 to 255 and the window's minimum 1016 to 1081 in the same pass, so the two changes cancelled and
|
||||
the answer stayed right while the working went stale.
|
||||
· 214, now that HostsScreen's board is inset 26 a side rather than 16 — see that file's own remark on
|
||||
why every screen frames its content the same way. Twenty pixels of board is twenty pixels the cards
|
||||
no longer have, and this is where they come from.
|
||||
|
||||
◆ THE TEST THAT CATCHES THIS IS NOT THE HARNESS. See
|
||||
ScreenLayoutTests.TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen, which counts columns
|
||||
because that is the thing this number exists to buy and the thing no fit assertion can see.
|
||||
-->
|
||||
<Style Selector="Border.tile">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
@@ -876,7 +979,7 @@
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Width" Value="224" />
|
||||
<Setter Property="Width" Value="214" />
|
||||
<Setter Property="Margin" Value="0,0,10,10" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover Border.tile">
|
||||
@@ -1169,6 +1272,27 @@
|
||||
<Setter Property="Fill" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Amber, and it does not contradict the rule above it. Green is what is true and this is not yet true;
|
||||
purple is what you can press and a dot is not pressable. What is left is the caveat colour, which is
|
||||
exactly what a connection still being made is. The same amber the connecting card's track uses, from
|
||||
the same brush, so that the tab and the card the tab opens agree.
|
||||
-->
|
||||
<Style Selector="Ellipse.dot.connecting">
|
||||
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Red, and it is the fourth state this dot has rather than a recolouring of the grey one. Grey is a
|
||||
shell that ended — somebody typed `exit`, and nothing is wrong with the machine. This is a connection
|
||||
that went out from under a session that had not finished, which is the one worth looking at: the two
|
||||
used to be the same dot, so a tab that had been cut off and a tab somebody had closed down properly
|
||||
were indistinguishable in the strip. See TerminalTabViewModel.EndedSummary.
|
||||
-->
|
||||
<Style Selector="Ellipse.dot.dropped">
|
||||
<Setter Property="Fill" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The accent strip a selected row carries, drawn by the row template rather than by the item, because the
|
||||
item's presenter is the thing the theme keeps repainting.
|
||||
|
||||
@@ -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
|
||||
@@ -113,14 +190,75 @@ internal sealed partial class DodoSshApp : Application
|
||||
var browser = new SystemBrowserLauncher();
|
||||
|
||||
// Both chosen once, here, because each is a property of the machine rather than of any session. A
|
||||
// computer with a usable TPM gets the store that keeps a device key behind a Windows consent prompt;
|
||||
// anything else gets one that reports itself unavailable, so unlock keeps asking for the passphrase
|
||||
// Windows box with a usable TPM gets a consent prompt, a Mac with a Secure Enclave gets Touch ID, a
|
||||
// Linux box whose TPM this user may open gets a PIN the TPM checks — see PinPrompt — and anything
|
||||
// else gets a store that reports itself unavailable, so unlock keeps asking for the passphrase
|
||||
// (ADR 0007). The update channel answers the same shape of question about how this copy was
|
||||
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0013.
|
||||
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
|
||||
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths, PinPrompt(desktop));
|
||||
var updates = UpdateChannels.ForThisMachine();
|
||||
|
||||
var viewModel = new MainWindowViewModel(
|
||||
var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections,
|
||||
browser, updates);
|
||||
|
||||
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, 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>
|
||||
/// <remarks>
|
||||
/// Split out of <see cref="Compose"/> for length rather than for reuse — it has one caller, and the
|
||||
/// method it came out of is at the limit the analyzers set.
|
||||
/// </remarks>
|
||||
private static MainWindowViewModel BuildShell(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
ClientPaths paths,
|
||||
ClientCacheFactory caches,
|
||||
TerminalWorkspace workspace,
|
||||
VaultKnownHostStore knownHosts,
|
||||
IDeviceKeyStore deviceKeys,
|
||||
SshNetConnectionFactory connections,
|
||||
SystemBrowserLauncher browser,
|
||||
IUpdateChannel updates) =>
|
||||
new(
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
@@ -140,18 +278,88 @@ internal sealed partial class DodoSshApp : Application
|
||||
.ConfigureAwait(false),
|
||||
|
||||
copyToClipboard: ClipboardWriter(desktop),
|
||||
updates: updates);
|
||||
readClipboard: ClipboardReader(desktop),
|
||||
updates: updates,
|
||||
openInEditor: OpenInDefaultEditorAsync,
|
||||
watchFile: WatchFile);
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
/// <summary>
|
||||
/// Hands a local file to whatever this machine opens that kind of file with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
|
||||
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
|
||||
/// Services binding depending on which desktop this is, and watching a file is a platform object with
|
||||
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
|
||||
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
|
||||
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
|
||||
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
|
||||
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
|
||||
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static Task OpenInDefaultEditorAsync(string path)
|
||||
{
|
||||
using var opened = System.Diagnostics.Process.Start(
|
||||
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
|
||||
// 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);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches one file for saves, until the handle is disposed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The directory is watched and the name filtered, which is not the same as watching the file.</b>
|
||||
/// A great many editors do not write in place: they write a temporary file beside the original and
|
||||
/// rename it over the top, which to a watcher pointed at an inode is the file disappearing. Watching the
|
||||
/// containing directory for that name catches the rename as a <c>Created</c>, which is what makes this
|
||||
/// work with the editors people actually use.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every kind of change is one callback, and the shell debounces nothing.</b> One save can raise two
|
||||
/// or three events; what the shell does with each is stat the host and compare, which is a round trip
|
||||
/// but a small one, and the alternative — a timer here — is a delay before somebody's edit reaches the
|
||||
/// server for the sake of saving it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static FileSystemWatcher WatchFile(string path, Action onChanged)
|
||||
{
|
||||
var watcher = new FileSystemWatcher(
|
||||
Path.GetDirectoryName(path)!, Path.GetFileName(path))
|
||||
{
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName,
|
||||
};
|
||||
|
||||
watcher.Changed += (_, _) => onChanged();
|
||||
watcher.Created += (_, _) => onChanged();
|
||||
watcher.Renamed += (_, _) => onChanged();
|
||||
|
||||
watcher.EnableRaisingEvents = true;
|
||||
|
||||
return watcher;
|
||||
}
|
||||
|
||||
/// <summary>How the Linux device key store asks for a PIN, and the only thing that ever calls it.</summary>
|
||||
/// <remarks>
|
||||
/// The lifetime is captured rather than the window, so this can be composed before there is one —
|
||||
/// composition runs first, and <c>DevicePinWindow.AskAsync</c> answers null when there is nothing to
|
||||
/// sit over. Windows and macOS never reach it: on those platforms the consent dialog is the operating
|
||||
/// system's and this process is not in the conversation. See ADR 0007.
|
||||
/// </remarks>
|
||||
private static Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> PinPrompt(
|
||||
IClassicDesktopStyleApplicationLifetime desktop) =>
|
||||
(purpose, cancellationToken) =>
|
||||
DevicePinWindow.AskAsync(() => desktop.MainWindow, purpose, cancellationToken);
|
||||
|
||||
/// <remarks>
|
||||
/// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and
|
||||
/// blocking the UI thread on their disposal is how an application comes to take several seconds to close —
|
||||
@@ -161,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
MainWindowViewModel viewModel,
|
||||
TerminalWorkspace workspace,
|
||||
ClientCacheFactory caches)
|
||||
ClientCacheFactory caches,
|
||||
IAsyncDisposable others)
|
||||
{
|
||||
var shuttingDown = false;
|
||||
|
||||
@@ -180,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();
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 786 B |
Binary file not shown.
|
After Width: | Height: | Size: 1002 B |
@@ -225,3 +225,37 @@ $icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
|
||||
$icns.Dispose()
|
||||
|
||||
Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)"
|
||||
|
||||
# ---- dodossh-<size>.png, for the Linux desktop entry ---------------------------------------------
|
||||
#
|
||||
# The third platform, and the only one that wants the raster loose rather than in a container. A
|
||||
# freedesktop icon theme is a directory tree — hicolor/48x48/apps/dodossh.png and friends — and the
|
||||
# RPM built by scripts/ci-linux-rpm.sh installs one file per size into it.
|
||||
#
|
||||
# Written here for the reason this whole file exists: one geometry, regenerated in one command. The
|
||||
# alternative was to have the packaging script pull the 256px frame out of dodossh.ico at build time,
|
||||
# which works — the .ico's entries really are PNGs — and puts a second place that has to know how an
|
||||
# ICO directory is laid out. It also silently ties the Linux icon to whatever sizes Windows happens
|
||||
# to want.
|
||||
#
|
||||
# ◆ THE TILE BLEEDS TO THE EDGE HERE, AS IT DOES ON WINDOWS.
|
||||
#
|
||||
# tileFraction stays 1.0. The 0.8047 inset above is Apple's icon grid and nothing else — GNOME, KDE
|
||||
# and every panel that reads hicolor draw the icon at the size they asked for, with no shadow and no
|
||||
# magnification to leave air for. An inset icon would simply be the small one in the launcher.
|
||||
#
|
||||
# ◆ AND THE SIZES ARE NOT A ROUND NUMBER OF THEM.
|
||||
#
|
||||
# 48 is the size a GNOME dash and most panels ask for, 128 and 256 are what a large-icon file manager
|
||||
# and GNOME Software use, and 64 is between them because the 48 upscaled to it is visibly soft. There
|
||||
# is deliberately nothing below 48: the hairline floor in New-MarkPng keeps a 16px mark legible, but
|
||||
# no Linux desktop asks this theme for one — window titlebars there draw no icon at all.
|
||||
$linuxSizes = @(48, 64, 128, 256)
|
||||
|
||||
foreach ($size in $linuxSizes)
|
||||
{
|
||||
[byte[]]$png = New-MarkPng $size
|
||||
$pngTarget = Join-Path $PSScriptRoot "dodossh-$size.png"
|
||||
[System.IO.File]::WriteAllBytes($pngTarget, $png)
|
||||
Write-Output "Wrote $pngTarget ($((Get-Item $pngTarget).Length) bytes)"
|
||||
}
|
||||
@@ -151,6 +151,14 @@
|
||||
interface declared in DodoSSH.Client.Session.
|
||||
-->
|
||||
<PackageReference Include="Velopack" />
|
||||
|
||||
<!--
|
||||
Here for the same reason and with the same boundary as Velopack above: LinuxDeviceKeyStore is a
|
||||
Linux-only implementation of an interface declared in DodoSSH.Client.Session, and it lives in
|
||||
Platform/ beside the Windows and macOS stores. The Android head has its own keystore and must
|
||||
never see this one.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.TSS" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Session;
|
||||
using Tpm2Lib;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>Why the store is asking for a PIN.</summary>
|
||||
/// <remarks>
|
||||
/// The two cases need different words in front of a person — one is choosing a PIN and the other is
|
||||
/// entering one — and the store is the only thing that knows which is happening. Passing an enum rather
|
||||
/// than two delegates keeps the prompt a single seam.
|
||||
/// </remarks>
|
||||
public enum DevicePinPurpose
|
||||
{
|
||||
/// <summary>A PIN is being chosen, as part of registering this machine.</summary>
|
||||
Choosing,
|
||||
|
||||
/// <summary>A PIN is being asked for, to unlock.</summary>
|
||||
Entering,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the device key sealed inside the TPM, released only against a PIN the TPM itself checks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The Linux answer to <see cref="WindowsDeviceKeyStore"/> and <c>MacDeviceKeyStore</c>, and ADR 0007's
|
||||
/// bar is the one it has to clear: <b>the enforcement must be the platform's rather than this process's.</b>
|
||||
/// A store that drew its own prompt over a file it could read anyway would be the mistake that ADR's
|
||||
/// 2026-07-30 amendment exists to record — malware running as the user skips the prompt and reads the file.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Here the TPM holds the key and the TPM checks the PIN.</b> The 32-byte device key is sealed into a
|
||||
/// keyedhash object whose <c>userAuth</c> is derived from the PIN, so unsealing is a command the TPM
|
||||
/// refuses without it. The blob on disk is encrypted to the TPM's storage hierarchy and is useless on any
|
||||
/// other machine, and the PIN is never compared by anything in this assembly. Malware running as the user
|
||||
/// can ask; it cannot answer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>And the TPM counts the failures, which is what makes a short PIN safe.</b> The sealed object
|
||||
/// deliberately does not carry <c>NoDA</c>, so every wrong answer advances the dictionary-attack counter
|
||||
/// and the TPM stops answering at all once it trips. Measured against a software TPM: four wrong PINs and
|
||||
/// it refuses the right one too, until the lockout recovers. That is the property a four-digit secret has
|
||||
/// on this platform and cannot have in a file.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>What this does NOT match, stated because the difference is real and invisible.</b> Windows Hello
|
||||
/// and Touch ID raise their prompt from the platform — a secure desktop on one, a system sheet on the
|
||||
/// other — so the process asking never sees the secret. Linux has no equivalent to borrow, so the PIN is
|
||||
/// typed into a window this application draws, and a sufficiently determined attacker already running as
|
||||
/// the user could draw a convincing copy of it. The mitigation is that they could do the same for the
|
||||
/// passphrase screen, which is a much larger prize, so this is not a capability the feature creates. It is
|
||||
/// still a weaker guarantee than the other two desktops have and ADR 0007 says so rather than implying
|
||||
/// parity.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Availability still depends on reaching the device, and the package arranges that rather than asking
|
||||
/// the user to.</b> Fedora leaves <c>/dev/tpmrm0</c> as <c>root:tss</c> mode 0660, so without help this
|
||||
/// process could not open it on an ordinary desktop account. The RPM installs
|
||||
/// <c>build/linux/71-dodossh-tpm.rules</c>, which tags the device <c>uaccess</c> — logind then grants an
|
||||
/// ACL to whoever is logged in at the machine, and takes it away when they log out. Not over SSH and not
|
||||
/// to a user on an inactive VT.
|
||||
/// <para>
|
||||
/// So a build run from a checkout, or one on a machine where an administrator has masked that rule, still
|
||||
/// finds the device unreadable and reports unavailable — which keeps the passphrase path exactly as it was.
|
||||
/// The rule carries its own reasoning, including the lockout denial-of-service it accepts; see ADR 0007
|
||||
/// decision 4.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("linux")]
|
||||
public sealed class LinuxDeviceKeyStore : IDeviceKeyStore
|
||||
{
|
||||
/// <summary>What the blob on disk starts with, so a file from elsewhere is refused rather than parsed.</summary>
|
||||
private static readonly byte[] Magic = "DSHTPMv1"u8.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Domain separation for the PIN, so the value handed to the TPM is this application's and not the
|
||||
/// user's PIN as typed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The TPM compares an authValue of at most one digest in length, so something has to reduce a typed
|
||||
/// string to bytes; hashing with a label rather than truncating means a PIN reused elsewhere does not
|
||||
/// arrive at the TPM as the same bytes it does there. It buys nothing against an attacker who has the
|
||||
/// blob and the machine — they have this constant too — and it costs one line.
|
||||
/// </remarks>
|
||||
private const string PinLabel = "dodossh/devicekey/pin/v1";
|
||||
|
||||
private readonly ClientPaths paths;
|
||||
private readonly Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin;
|
||||
private readonly Func<Tpm2Device> openDevice;
|
||||
|
||||
/// <summary>Creates a store against this machine's TPM.</summary>
|
||||
/// <param name="paths">Where the sealed blob lives.</param>
|
||||
/// <param name="askForPin">
|
||||
/// Shows the PIN prompt and answers with what was typed, or null if the user declined. Declining is
|
||||
/// ordinary — see <see cref="IDeviceKeyStore"/> — and lands on the passphrase.
|
||||
/// </param>
|
||||
public LinuxDeviceKeyStore(
|
||||
ClientPaths paths,
|
||||
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
||||
: this(paths, askForPin, static () => new LinuxTpmDevice())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates a store against a named TPM, which is how the tests reach a simulator.</summary>
|
||||
/// <remarks>
|
||||
/// The seam exists because this class is testable in a way the other two stores are not, and that is
|
||||
/// worth keeping. Windows and macOS put the prompt inside the platform, so a test that exercised
|
||||
/// sealing would block forever on a dialog — <c>WindowsDeviceKeyStoreTests</c> records the ten minutes
|
||||
/// that took to discover. Here the prompt is a delegate and the TPM is a device, so both can be
|
||||
/// supplied and the whole path runs unattended.
|
||||
/// </remarks>
|
||||
internal LinuxDeviceKeyStore(
|
||||
ClientPaths paths,
|
||||
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin,
|
||||
Func<Tpm2Device> openDevice)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(askForPin);
|
||||
ArgumentNullException.ThrowIfNull(openDevice);
|
||||
|
||||
this.paths = paths;
|
||||
this.askForPin = askForPin;
|
||||
this.openDevice = openDevice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The parent every sealed object hangs off, as a template rather than as a stored handle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Recreated from a fixed template on every use, and not made persistent.</b> A persistent handle
|
||||
/// would occupy one of the TPM's small number of NV slots forever, would have to be cleaned up by
|
||||
/// <see cref="ForgetAsync"/>, and would collide with whatever else on the machine had chosen the same
|
||||
/// index. Deriving it costs a command and leaves nothing behind.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>ECC P-256 rather than RSA-2048, and that is a user-visible choice.</b> The two are equally
|
||||
/// standard as storage roots. Creating an RSA primary on a discrete TPM takes seconds — the key is
|
||||
/// generated, not derived — and this runs on the unlock path, so it would show up as the application
|
||||
/// hanging every time somebody opened their vault. The prototype this was built from used RSA and that
|
||||
/// is exactly what it was changed for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>NoDA</c> is set <em>here</em> and deliberately not on the sealed object. The parent has no
|
||||
/// authValue to guess, so counting failures against it would only give an attacker a way to lock the
|
||||
/// machine's TPM out by asking for something they were never going to get.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static TpmPublic PrimaryTemplate => new(
|
||||
TpmAlgId.Sha256,
|
||||
ObjectAttr.Restricted | ObjectAttr.Decrypt | ObjectAttr.FixedTPM | ObjectAttr.FixedParent
|
||||
| ObjectAttr.SensitiveDataOrigin | ObjectAttr.UserWithAuth | ObjectAttr.NoDA,
|
||||
null,
|
||||
new EccParms(
|
||||
new SymDefObject(TpmAlgId.Aes, 128, TpmAlgId.Cfb),
|
||||
new NullAsymScheme(),
|
||||
EccCurve.TpmEccNistP256,
|
||||
new NullKdfScheme()),
|
||||
new EccPoint());
|
||||
|
||||
/// <summary>
|
||||
/// The sealed object: bytes the TPM holds, released against an authValue the TPM checks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keyedhash with a null scheme is TPM 2.0's "just keep these bytes" object. <c>UserWithAuth</c> is
|
||||
/// what makes the authValue the thing that opens it, and the deliberate <em>absence</em> of
|
||||
/// <c>NoDA</c> is what puts every wrong PIN on the dictionary-attack counter. Removing that one
|
||||
/// attribute would leave a four-digit secret that could be guessed at full speed by anything holding
|
||||
/// the blob and the machine, which is the whole of the protection this class offers.
|
||||
/// </remarks>
|
||||
private static TpmPublic SealedTemplate => new(
|
||||
TpmAlgId.Sha256,
|
||||
ObjectAttr.FixedTPM | ObjectAttr.FixedParent | ObjectAttr.UserWithAuth,
|
||||
null,
|
||||
new KeyedhashParms(new NullSchemeKeyedhash()),
|
||||
new Tpm2bDigestKeyedhash());
|
||||
|
||||
/// <summary>Whether this machine has a TPM this process may use.</summary>
|
||||
/// <remarks>
|
||||
/// Probes by creating and flushing a primary rather than by looking for the device node, for the
|
||||
/// reason <c>WindowsDeviceKeyStore.IsSupported</c> gives about its own provider: the node can be
|
||||
/// present on a machine where the first real command then fails. Permissions are the common case here
|
||||
/// and they fail at open; an owner hierarchy with an authValue set is the rarer one and fails at
|
||||
/// CreatePrimary. Neither is a fault and both mean the same thing.
|
||||
/// </remarks>
|
||||
internal static bool IsSupported() => IsSupported(static () => new LinuxTpmDevice());
|
||||
|
||||
/// <inheritdoc cref="IsSupported()" />
|
||||
internal static bool IsSupported(Func<Tpm2Device> openDevice)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(openDevice);
|
||||
|
||||
try
|
||||
{
|
||||
using var device = openDevice();
|
||||
device.Connect();
|
||||
|
||||
var tpm = new Tpm2(device);
|
||||
var primary = CreatePrimary(tpm);
|
||||
|
||||
tpm.FlushContext(primary);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Every way this fails means the same thing to the caller: no device key here, so keep asking
|
||||
// for the passphrase. A missing device node throws IOException, a device this user may not
|
||||
// open throws UnauthorizedAccessException, and a TPM that refuses the command throws
|
||||
// TpmException — and on a machine with no TPM at all the constructor itself may throw before
|
||||
// any of that.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||
ValueTask.FromResult(IsSupported(openDevice));
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Asks for a PIN first and writes nothing if the user declines, which matters more here than the
|
||||
/// symmetry suggests: <c>SaveAsync</c> is called as part of registering this machine, and a half-done
|
||||
/// registration would leave a wrap on the server that nothing can open.
|
||||
/// </remarks>
|
||||
public async ValueTask SaveAsync(
|
||||
ReadOnlyMemory<byte> devicePrivateKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pin = await askForPin(DevicePinPurpose.Choosing, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrEmpty(pin))
|
||||
{
|
||||
throw new OperationCanceledException("No PIN was chosen, so no device key was stored.");
|
||||
}
|
||||
|
||||
var key = devicePrivateKey.ToArray();
|
||||
|
||||
// Off the calling thread, because every command below blocks on the chip. Creating the primary
|
||||
// alone is hundreds of milliseconds on real hardware, and this is called from the unlock path —
|
||||
// which on the desktop head is the UI thread.
|
||||
var packed = await Task.Run(() => Seal(pin, key), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
paths.EnsureCreated();
|
||||
|
||||
await File.WriteAllBytesAsync(paths.DeviceKeyFile, packed, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Seals the key into the TPM and answers with the blob to store.</summary>
|
||||
private byte[] Seal(string pin, byte[] devicePrivateKey)
|
||||
{
|
||||
var auth = PinAuth(pin);
|
||||
|
||||
try
|
||||
{
|
||||
using var device = openDevice();
|
||||
device.Connect();
|
||||
|
||||
var tpm = new Tpm2(device);
|
||||
var primary = CreatePrimary(tpm);
|
||||
|
||||
try
|
||||
{
|
||||
var priv = tpm.Create(
|
||||
primary,
|
||||
new SensitiveCreate(auth, devicePrivateKey),
|
||||
SealedTemplate,
|
||||
null,
|
||||
[],
|
||||
out var pub,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
return Pack(pub, priv);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tpm.FlushContext(primary);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(auth);
|
||||
CryptographicOperations.ZeroMemory(devicePrivateKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>One attempt per call, and a wrong PIN is not retried here.</b> Every wrong answer advances the
|
||||
/// TPM's lockout counter, so a loop offering three tries would spend a third of the machine's budget
|
||||
/// on one mistyped digit. Returning null lands on the passphrase, which always works; somebody who
|
||||
/// wants to try the PIN again asks for a device unlock again, which is a deliberate act rather than a
|
||||
/// loop.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A locked-out TPM is deliberately indistinguishable from a wrong PIN.</b>
|
||||
/// <see cref="IDeviceKeyStore.TryLoadAsync"/> asks for exactly that and the reasoning holds here: the
|
||||
/// caller does the same thing in every case, and "your TPM is in lockout" describes the keystore
|
||||
/// rather than telling somebody something they can act on — the answer is still to type the
|
||||
/// passphrase.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(paths.DeviceKeyFile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var stored = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!TryUnpack(stored, out var pub, out var priv))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// After the blob is known to be readable and before anything is asked of the user, because a
|
||||
// prompt raised over a file this cannot use is a question with no useful answer.
|
||||
var pin = await askForPin(DevicePinPurpose.Entering, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrEmpty(pin))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Task.Run(() => Unseal(pin, pub, priv), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Asks the TPM to release the key, and answers null however it declines.</summary>
|
||||
private byte[]? Unseal(string pin, TpmPublic pub, TpmPrivate priv)
|
||||
{
|
||||
var auth = PinAuth(pin);
|
||||
|
||||
try
|
||||
{
|
||||
using var device = openDevice();
|
||||
device.Connect();
|
||||
|
||||
var tpm = new Tpm2(device);
|
||||
var primary = CreatePrimary(tpm);
|
||||
|
||||
try
|
||||
{
|
||||
var loaded = tpm.Load(primary, priv, pub);
|
||||
|
||||
try
|
||||
{
|
||||
return tpm[auth].Unseal(loaded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tpm.FlushContext(loaded);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
tpm.FlushContext(primary);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// TpmException covers the wrong PIN and the lockout; the rest covers a TPM that has gone away
|
||||
// between the availability check and now, which a suspend or a firmware update can do.
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(auth);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Deleting the blob is the whole of it, and that is a property of sealing rather than an omission.
|
||||
/// The object lives nowhere but in that file — it was never made persistent inside the TPM, see
|
||||
/// <see cref="PrimaryTemplate"/> — so the bytes are unrecoverable the moment it is gone. There is no
|
||||
/// TPM state to clean up and nothing left behind for a later install to trip over.
|
||||
/// </remarks>
|
||||
public ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (File.Exists(paths.DeviceKeyFile))
|
||||
{
|
||||
File.Delete(paths.DeviceKeyFile);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Derives the TPM authValue from the PIN as typed.</summary>
|
||||
private static byte[] PinAuth(string pin) =>
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes($"{PinLabel} {pin}"));
|
||||
|
||||
private static TpmHandle CreatePrimary(Tpm2 tpm) =>
|
||||
tpm.CreatePrimary(
|
||||
TpmRh.Owner,
|
||||
new SensitiveCreate(),
|
||||
PrimaryTemplate,
|
||||
null,
|
||||
[],
|
||||
out _,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
/// <summary>Puts the two halves of the sealed object in one file.</summary>
|
||||
/// <remarks>
|
||||
/// Both are needed to load it and neither is secret — the private half is encrypted to this TPM's
|
||||
/// storage hierarchy, which is what makes the file useless anywhere else. Length-prefixed rather than
|
||||
/// concatenated because their sizes vary with the TPM's algorithms, and magic-prefixed so that a file
|
||||
/// left by another version, or by the Windows store on a dual-boot profile directory, is refused
|
||||
/// rather than fed to the TPM as a malformed structure.
|
||||
/// </remarks>
|
||||
private static byte[] Pack(TpmPublic pub, TpmPrivate priv)
|
||||
{
|
||||
var publicPart = pub.GetTpmRepresentation();
|
||||
var privatePart = priv.GetTpmRepresentation();
|
||||
|
||||
var packed = new byte[Magic.Length + 4 + publicPart.Length + 4 + privatePart.Length];
|
||||
var at = Magic.Length;
|
||||
|
||||
Magic.CopyTo(packed.AsSpan());
|
||||
|
||||
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), publicPart.Length);
|
||||
at += 4;
|
||||
publicPart.CopyTo(packed.AsSpan(at));
|
||||
at += publicPart.Length;
|
||||
|
||||
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), privatePart.Length);
|
||||
at += 4;
|
||||
privatePart.CopyTo(packed.AsSpan(at));
|
||||
|
||||
return packed;
|
||||
}
|
||||
|
||||
/// <summary>Reads back what <see cref="Pack"/> wrote, or answers false.</summary>
|
||||
private static bool TryUnpack(byte[] stored, out TpmPublic pub, out TpmPrivate priv)
|
||||
{
|
||||
pub = new TpmPublic();
|
||||
priv = new TpmPrivate();
|
||||
|
||||
try
|
||||
{
|
||||
if (stored.Length < Magic.Length + 8 || !stored.AsSpan(0, Magic.Length).SequenceEqual(Magic))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var at = Magic.Length;
|
||||
|
||||
var publicLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
|
||||
at += 4;
|
||||
|
||||
if (publicLength < 0 || at + publicLength + 4 > stored.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var publicPart = stored.AsSpan(at, publicLength).ToArray();
|
||||
at += publicLength;
|
||||
|
||||
var privateLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
|
||||
at += 4;
|
||||
|
||||
if (privateLength < 0 || at + privateLength != stored.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var privatePart = stored.AsSpan(at, privateLength).ToArray();
|
||||
|
||||
pub = Marshaller.FromTpmRepresentation<TpmPublic>(publicPart);
|
||||
priv = Marshaller.FromTpmRepresentation<TpmPrivate>(privatePart);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// A blob that does not parse is a blob this machine cannot use, which is the same answer as no
|
||||
// blob at all. Throwing here would mean a corrupted file stopped the application from starting.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>What a channel's release says it is publishing.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Written by <c>scripts/ci-linux-rpm.sh</c> and read here, and it is a small file this repository
|
||||
/// controls rather than the forge's own release JSON for the reason the android manifest gives: this is
|
||||
/// fetched on a six-hourly timer, and a document whose shape changes when the forge is upgraded is not a
|
||||
/// contract.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The comparison is <see cref="RpmVersion"/> and never <see cref="Version"/>.</b> That is the string
|
||||
/// <c>rpm</c> will order the install against, so comparing anything else would let this offer an update
|
||||
/// PackageKit then refuses — the same argument <c>AndroidChannelManifest</c> makes for <c>versionCode</c>.
|
||||
/// The two differ exactly where it matters: <c>0.0.1-alpha.0.143</c> and <c>0.0.1~alpha.0.143</c> are one
|
||||
/// build, and only the second sorts below <c>0.0.1</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="RpmVersion">The version-release string rpm knows the package by.</param>
|
||||
/// <param name="Version">What that build calls itself, for a human.</param>
|
||||
/// <param name="Rpm">The asset on the same release that holds it.</param>
|
||||
/// <param name="Sha256">The package's digest, lower-case hex.</param>
|
||||
internal sealed record LinuxChannelManifest(
|
||||
[property: JsonPropertyName("rpmVersion")] string RpmVersion,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("rpm")] string Rpm,
|
||||
[property: JsonPropertyName("sha256")] string Sha256);
|
||||
|
||||
/// <summary>One release as the forge describes it. Only the assets are read.</summary>
|
||||
internal sealed record LinuxForgeRelease(
|
||||
[property: JsonPropertyName("assets")] IReadOnlyList<LinuxForgeAsset>? Assets);
|
||||
|
||||
/// <summary>One file attached to a release.</summary>
|
||||
internal sealed record LinuxForgeAsset(
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("browser_download_url")] string? DownloadUrl);
|
||||
|
||||
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(LinuxChannelManifest))]
|
||||
[JsonSerializable(typeof(LinuxForgeRelease))]
|
||||
internal sealed partial class LinuxForgeJsonContext : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a newer RPM from the project's own forge and asks the system's package manager to install it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Nothing here replaces anything.</b> That is the difference from
|
||||
/// <see cref="VelopackUpdateChannel"/> and it is the decision ADR 0013 rule 11 records rather than an
|
||||
/// unfinished half: on this platform the package manager is already an updater the user trusts, audits and
|
||||
/// controls, so this class does the two things the package manager cannot — notice that a build exists and
|
||||
/// fetch it — and then hands the file to PackageKit, which raises polkit and asks. The shape is the
|
||||
/// phone's, not the desktop's: <see cref="ApplyingEndsTheProcess"/> is false, and the answer may be no.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The feed is a constant and the deployment is never asked.</b> ADR 0011 rule 2, carried unchanged
|
||||
/// from both other channels: an operator who could answer the update check could pin a chosen user to a
|
||||
/// build with a known hole, without holding any key at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The digest is checked and is not a signature.</b> Velopack verifies what it downloads and Android
|
||||
/// refuses a package whose signature does not match the installed one; an RPM fetched over HTTPS and
|
||||
/// handed to PackageKit has neither. So the manifest names a digest and this refuses anything else —
|
||||
/// which catches a truncated download, a caching proxy and a corrupted mirror, and catches nothing at all
|
||||
/// about who built the file, because the same job wrote both. Provenance here is the constant address; a
|
||||
/// signed repository is what would improve on it, and ADR 0013 rule 11 says why there is not one yet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("linux")]
|
||||
internal sealed class LinuxUpdateChannel : IUpdateChannel
|
||||
{
|
||||
/// <summary>The project's own forge, and the one address in this file.</summary>
|
||||
/// <remarks>
|
||||
/// The owner half of the path matters for the reason <c>AndroidUpdateChannel</c> records: the
|
||||
/// repository moved between organisations and Gitea leaves a 301 behind, so a client naming the old
|
||||
/// one works until somebody deletes the redirect.
|
||||
/// </remarks>
|
||||
private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH";
|
||||
|
||||
private readonly string channel;
|
||||
private readonly string packageName;
|
||||
private readonly string dataDirectory;
|
||||
private readonly HttpClient http;
|
||||
private readonly Func<string, string?> installedVersion;
|
||||
private readonly Action<string> handToInstaller;
|
||||
|
||||
private (string Version, string RpmVersion, string Url, string Sha256)? found;
|
||||
private string? fetched;
|
||||
|
||||
/// <summary>Creates a channel for the running build.</summary>
|
||||
internal LinuxUpdateChannel(string channel)
|
||||
: this(
|
||||
channel,
|
||||
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
|
||||
new HttpClient(),
|
||||
InstalledRpmVersion,
|
||||
HandToPackageKit)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates a channel with its outside world supplied, which is how the tests reach it.</summary>
|
||||
internal LinuxUpdateChannel(
|
||||
string channel,
|
||||
string dataDirectory,
|
||||
HttpClient http,
|
||||
Func<string, string?> installedVersion,
|
||||
Action<string> handToInstaller)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(channel);
|
||||
ArgumentException.ThrowIfNullOrEmpty(dataDirectory);
|
||||
ArgumentNullException.ThrowIfNull(http);
|
||||
ArgumentNullException.ThrowIfNull(installedVersion);
|
||||
ArgumentNullException.ThrowIfNull(handToInstaller);
|
||||
|
||||
this.channel = channel;
|
||||
this.dataDirectory = dataDirectory;
|
||||
this.http = http;
|
||||
this.installedVersion = installedVersion;
|
||||
this.handToInstaller = handToInstaller;
|
||||
|
||||
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal)
|
||||
? "dodossh-nightly"
|
||||
: "dodossh";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// True unconditionally, because whether this copy is one that can be updated was already decided by
|
||||
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class
|
||||
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at
|
||||
/// composition rather than at every call site.
|
||||
/// </remarks>
|
||||
public bool IsSupported => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CurrentVersion => ClientVersion.Current;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// False, and for the phone's reason rather than the desktop's. Applying hands a file to PackageKit
|
||||
/// and returns; polkit then asks, and the user may say no. A caller that had already torn the vault
|
||||
/// down would answer "not now" with a locked vault and every shell gone.
|
||||
/// </remarks>
|
||||
public bool ApplyingEndsTheProcess => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="HttpRequestException">The forge could not be reached, or refused.</exception>
|
||||
/// <exception cref="InvalidOperationException">The release is there and does not carry a usable build.</exception>
|
||||
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var release = await ReadAsync(
|
||||
ReleaseUrl(),
|
||||
LinuxForgeJsonContext.Default.LinuxForgeRelease,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var manifestName = $"linux-{channel}.json";
|
||||
|
||||
if (Asset(release, manifestName) is not { } manifestAsset)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The {channel} release carries no {manifestName}, so there is nothing saying what it "
|
||||
+ "publishes.");
|
||||
}
|
||||
|
||||
var manifest = await ReadAsync(
|
||||
manifestAsset,
|
||||
LinuxForgeJsonContext.Default.LinuxChannelManifest,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The {channel} release's {manifestName} could not be read as a manifest.");
|
||||
}
|
||||
|
||||
// The one place null is returned, and it means what null is documented to mean: this build is
|
||||
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
|
||||
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Asset(release, manifest.Rpm) is not { } rpm)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The {channel} release advertises {manifest.Version} but carries no {manifest.Rpm}.");
|
||||
}
|
||||
|
||||
found = (manifest.Version, manifest.RpmVersion, rpm, manifest.Sha256);
|
||||
|
||||
return new AvailableUpdate(manifest.Version);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DownloadAsync(
|
||||
AvailableUpdate update,
|
||||
IProgress<int> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
ArgumentNullException.ThrowIfNull(progress);
|
||||
|
||||
if (found is not { } target
|
||||
|| !string.Equals(target.Version, update.Version, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"That update did not come from this channel's last check. Check again before downloading.");
|
||||
}
|
||||
|
||||
// Under the profile directory rather than /tmp, because a package this size on a tmpfs is a
|
||||
// machine that runs out of memory downloading an update. One name, overwritten, so a client that
|
||||
// fetched three updates it never installed is not holding three hundred megabytes.
|
||||
var directory = Path.Combine(dataDirectory, "updates");
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var path = Path.Combine(directory, $"{packageName}-update.rpm");
|
||||
|
||||
await FetchAsync(target.Url, path, progress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// ◆ Verified before it is ever named to the installer, and the file is removed if it does not
|
||||
// match. Leaving a package that failed its digest on disk would put a file somebody could install
|
||||
// by hand next to a filename that says it is an update.
|
||||
var digest = Convert.ToHexStringLower(await Sha256OfAsync(path, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
if (!string.Equals(digest, target.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(path);
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"The downloaded package is not the one {channel} published: expected {target.Sha256}, "
|
||||
+ $"got {digest}.");
|
||||
}
|
||||
|
||||
fetched = path;
|
||||
}
|
||||
|
||||
/// <summary>Streams one URL to one path, reporting progress as it goes.</summary>
|
||||
private async Task FetchAsync(
|
||||
string url,
|
||||
string path,
|
||||
IProgress<int> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await http
|
||||
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var total = response.Content.Headers.ContentLength ?? 0;
|
||||
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var destination = File.Create(path);
|
||||
|
||||
await using (stream.ConfigureAwait(false))
|
||||
await using (destination.ConfigureAwait(false))
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
long copied = 0;
|
||||
int read;
|
||||
|
||||
while ((read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
copied += read;
|
||||
|
||||
if (total > 0)
|
||||
{
|
||||
progress.Report((int)(copied * 100 / total));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This installs nothing and returns immediately.</b> It asks PackageKit — the freedesktop service
|
||||
/// every RPM distribution runs — to install a local file, which raises a polkit prompt drawn by the
|
||||
/// desktop rather than by this application. The user may refuse, and refusing is not a failure.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The process is not restarted and must not be.</b> Replacing files under a running process is
|
||||
/// ordinary on Linux: the old inodes stay mapped and this build keeps running until somebody closes
|
||||
/// it. That is the right behaviour for an application that deliberately keeps shells open across a
|
||||
/// lock — the same argument ADR 0013 makes for waiting for a restart the user asks for — and it is why
|
||||
/// the name inherited from the interface reads oddly here. There is no equivalent of Android's
|
||||
/// "the platform ends the process", so nothing ends it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public void ApplyAndRestart(AvailableUpdate update)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
|
||||
if (fetched is not { } path || !File.Exists(path))
|
||||
{
|
||||
throw new InvalidOperationException("There is no downloaded update to install.");
|
||||
}
|
||||
|
||||
handToInstaller(path);
|
||||
}
|
||||
|
||||
/// <summary>Which release this build's channel reads.</summary>
|
||||
/// <remarks>
|
||||
/// <c>releases/latest</c> skips prereleases, which keeps the nightly — published as one — out of the
|
||||
/// release channel's answer even though both live on the same forge.
|
||||
/// </remarks>
|
||||
private string ReleaseUrl() =>
|
||||
string.Equals(channel, "nightly", StringComparison.Ordinal)
|
||||
? $"{RepositoryApi}/releases/tags/nightly-desktop"
|
||||
: $"{RepositoryApi}/releases/latest";
|
||||
|
||||
private static string? Asset(LinuxForgeRelease? release, string name) =>
|
||||
release?.Assets?.FirstOrDefault(
|
||||
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
|
||||
|
||||
private async Task<T?> ReadAsync<T>(
|
||||
string url,
|
||||
JsonTypeInfo<T> shape,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stream = await http.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync(stream, shape, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> Sha256OfAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var stream = File.OpenRead(path);
|
||||
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
return await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What rpm records for the installed package, which is the number a newer build must beat.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Read from the package database rather than from the assembly, for the reason
|
||||
/// <c>AndroidUpdateChannel.ReadInstalledVersionCode</c> gives about reading it from the platform: the
|
||||
/// assembly's version is a SemVer string and this comparison has to be the one the installer will
|
||||
/// make. Deriving the rpm version from the assembly here would mean a second copy of the translation
|
||||
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every
|
||||
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then
|
||||
/// an install PackageKit declines rather than a security fix that never arrives.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static string? InstalledRpmVersion(string packageName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(new ProcessStartInfo("rpm")
|
||||
{
|
||||
ArgumentList = { "-q", "--queryformat", "%{VERSION}-%{RELEASE}", packageName },
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
});
|
||||
|
||||
if (process is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var answer = process.StandardOutput.ReadToEnd().Trim();
|
||||
|
||||
process.WaitForExit(TimeSpan.FromSeconds(10));
|
||||
|
||||
return process.ExitCode == 0 && answer.Length > 0 ? answer : null;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Asks PackageKit to install a local file, and lets polkit do the asking.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Over <c>gdbus</c> rather than a D-Bus library, and that is a dependency argument rather than a
|
||||
/// stylistic one.</b> <c>gdbus</c> ships in glib2, which this package already depends on twice over —
|
||||
/// GTK and WebKitGTK are the terminal — so it is present on every machine that can run this at all.
|
||||
/// Adding a managed D-Bus stack to reach two method calls would be a package, a lock file entry and a
|
||||
/// second thing to keep current, for no capability. <c>BrowserLauncher</c> settles the same trade the
|
||||
/// same way one layer down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two calls, because PackageKit's interface is a factory.</b> <c>CreateTransaction</c> returns an
|
||||
/// object path and the work is asked of that; a transaction is single-use, so this cannot be cached.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The flags word is zero, and zero is load-bearing.</b> Bit 0 is <c>ONLY_TRUSTED</c>. This
|
||||
/// package is not signed — ADR 0013 rule 11 says why, and why that is not decision 8's "unsigned for
|
||||
/// now" — so requiring trust would make every update fail at the last step. Clearing it is what routes
|
||||
/// the request to polkit's <c>package-install-untrusted</c> action, which asks for an administrator
|
||||
/// rather than proceeding quietly. The user sees what they are agreeing to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void HandToPackageKit(string path)
|
||||
{
|
||||
var transaction = Gdbus(
|
||||
"--system",
|
||||
"--dest", "org.freedesktop.PackageKit",
|
||||
"--object-path", "/org/freedesktop/PackageKit",
|
||||
"--method", "org.freedesktop.PackageKit.CreateTransaction");
|
||||
|
||||
// gdbus prints a GVariant tuple — (objectpath '/17_beadcfae',) — and the path is the only thing
|
||||
// in it. Parsed by taking what is between the quotes rather than with a regular expression over a
|
||||
// format nobody has promised to keep.
|
||||
var opened = transaction.IndexOf('\'', StringComparison.Ordinal);
|
||||
var closed = transaction.LastIndexOf('\'');
|
||||
|
||||
if (opened < 0 || closed <= opened)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"PackageKit did not return a transaction to install into: {transaction}");
|
||||
}
|
||||
|
||||
var objectPath = transaction[(opened + 1)..closed];
|
||||
|
||||
Gdbus(
|
||||
"--system",
|
||||
"--dest", "org.freedesktop.PackageKit",
|
||||
"--object-path", objectPath,
|
||||
"--method", "org.freedesktop.PackageKit.Transaction.InstallFiles",
|
||||
"0",
|
||||
$"['{path}']");
|
||||
}
|
||||
|
||||
/// <summary>Runs one gdbus call and answers with its output.</summary>
|
||||
private static string Gdbus(params string[] arguments)
|
||||
{
|
||||
var start = new ProcessStartInfo("gdbus")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
using var process = Process.Start(start)
|
||||
?? throw new InvalidOperationException(
|
||||
"gdbus could not be started, so the package could not be handed to PackageKit.");
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
|
||||
process.WaitForExit(TimeSpan.FromSeconds(30));
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
// The message is shown to the user by UpdateViewModel, so it says what to do rather than what
|
||||
// failed. A missing PackageKit is the likely cause on a minimal install and is the one thing
|
||||
// they can fix.
|
||||
throw new InvalidOperationException(
|
||||
string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
"PackageKit would not take the package. Install it with `sudo dnf install PackageKit`, "
|
||||
+ "or install the file yourself: {0}. ({1})",
|
||||
arguments[^1],
|
||||
error.Trim()));
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Compares two RPM version strings the way <c>rpm</c> itself does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists rather than a SemVer comparison.</b> <c>AndroidUpdateChannel</c> settles the same
|
||||
/// question and its reasoning carries over exactly: the comparison has to be the one the installer will
|
||||
/// make, because anything else can offer an update the platform then refuses. On the phone that number is
|
||||
/// Android's <c>versionCode</c>; here it is the RPM version, and the authority that reads it is
|
||||
/// <c>rpm</c> — so this is <c>rpmvercmp</c> and not an approximation of it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It matters in a specific direction. The nightly channel is rolling and CI replaces it on every push, so
|
||||
/// the published version normally only rises — but a revert, a force-push or a re-run of an older commit
|
||||
/// can lower it, and a client comparing "is this different from mine" would then offer a downgrade that
|
||||
/// PackageKit declines. The user sees an update that fails, every six hours, forever.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The algorithm is rpm's, including the parts that look like bugs.</b> Segments of digits and segments
|
||||
/// of letters are compared separately and never against each other; everything that is neither is a
|
||||
/// separator, so <c>1.0_1</c> and <c>1.0.1</c> are equal. Leading zeros are stripped, so <c>1.01</c> and
|
||||
/// <c>1.1</c> are equal too. <c>~</c> sorts before anything including the end of the string, which is what
|
||||
/// makes <c>0.0.1~alpha.0.143</c> older than <c>0.0.1</c> and is the whole reason
|
||||
/// <c>scripts/ci-linux-rpm.sh</c> translates a SemVer prerelease to one. <c>^</c> sorts after everything
|
||||
/// except the end of the string.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Verified against <c>rpm</c>'s own answers rather than against a reading of the source: the cases in
|
||||
/// <c>RpmVersionTests</c> were generated by running <c>rpm.vercmp</c> in a container, so a disagreement is
|
||||
/// a disagreement with the program that will actually decide.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class RpmVersion
|
||||
{
|
||||
/// <summary>
|
||||
/// Orders two RPM version strings.
|
||||
/// </summary>
|
||||
/// <param name="left">The left version.</param>
|
||||
/// <param name="right">The right version.</param>
|
||||
/// <returns>-1, 0 or 1, as <c>rpmvercmp</c> would answer.</returns>
|
||||
/// <remarks>
|
||||
/// Null and empty are accepted and treated as equal to each other and older than anything else, which
|
||||
/// <c>rpm</c>'s own Lua binding refuses outright. The refusal is right for a packaging tool and wrong
|
||||
/// here: this compares a version read off a feed, and a feed that answered with an empty string should
|
||||
/// leave the client on the build it has rather than throwing on a background timer.
|
||||
/// </remarks>
|
||||
internal static int Compare(string? left, string? right)
|
||||
{
|
||||
var one = left ?? string.Empty;
|
||||
var two = right ?? string.Empty;
|
||||
|
||||
if (string.Equals(one, two, StringComparison.Ordinal))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int i = 0, j = 0;
|
||||
|
||||
while (i < one.Length || j < two.Length)
|
||||
{
|
||||
// Separators. Anything that is not alphanumeric, ~ or ^ divides segments and is skipped on
|
||||
// both sides independently — which is why 1.0_1 and 1.0.1 compare equal.
|
||||
Skip(one, ref i);
|
||||
Skip(two, ref j);
|
||||
|
||||
if (CompareMarkers(one, i, two, j) is { } marker)
|
||||
{
|
||||
if (marker != 0)
|
||||
{
|
||||
return marker;
|
||||
}
|
||||
|
||||
// Both sides carried the same marker, so step over it and keep going.
|
||||
i++;
|
||||
j++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i >= one.Length || j >= two.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (CompareSegments(one, ref i, two, ref j) is { } ordering)
|
||||
{
|
||||
return ordering;
|
||||
}
|
||||
}
|
||||
|
||||
// Both ran out together, so every segment matched and the only differences were separators.
|
||||
if (i >= one.Length && j >= two.Length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Whichever still has characters left has more version, and more version is newer.
|
||||
return i < one.Length ? 1 : -1;
|
||||
}
|
||||
|
||||
/// <summary>Advances past anything that divides segments rather than taking part in them.</summary>
|
||||
private static void Skip(string value, ref int at)
|
||||
{
|
||||
while (at < value.Length && !IsVersionCharacter(value[at]))
|
||||
{
|
||||
at++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orders the two sides when either is at a <c>~</c> or a <c>^</c>.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Null when neither side is at a marker, so the caller should compare segments; 0 when both are at
|
||||
/// the same one, so the caller should step over it; otherwise the ordering.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <c>~</c> sorts before everything including the end of the string, which is what carries the whole
|
||||
/// prerelease ordering. <c>^</c> sorts after everything <em>except</em> the end of the string, so
|
||||
/// 1.0^post is newer than 1.0 and 1.2.3 is older than 1.2.3^ — the end-of-string checks coming first
|
||||
/// is the difference between the two halves of that sentence.
|
||||
/// </remarks>
|
||||
private static int? CompareMarkers(string one, int i, string two, int j)
|
||||
{
|
||||
var oneTilde = i < one.Length && one[i] == '~';
|
||||
var twoTilde = j < two.Length && two[j] == '~';
|
||||
|
||||
if (oneTilde || twoTilde)
|
||||
{
|
||||
return oneTilde && twoTilde ? 0 : oneTilde ? -1 : 1;
|
||||
}
|
||||
|
||||
var oneCaret = i < one.Length && one[i] == '^';
|
||||
var twoCaret = j < two.Length && two[j] == '^';
|
||||
|
||||
if (!oneCaret && !twoCaret)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (i >= one.Length)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (j >= two.Length)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return oneCaret && twoCaret ? 0 : oneCaret ? -1 : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes one segment from each side and orders them, advancing past both.
|
||||
/// </summary>
|
||||
/// <returns>The ordering, or null if the two segments were equal and the walk should continue.</returns>
|
||||
private static int? CompareSegments(string one, ref int i, string two, ref int j)
|
||||
{
|
||||
var numeric = char.IsAsciiDigit(one[i]);
|
||||
|
||||
var start1 = i;
|
||||
var start2 = j;
|
||||
|
||||
Take(one, ref i, numeric);
|
||||
Take(two, ref j, numeric);
|
||||
|
||||
var segment1 = one.AsSpan(start1, i - start1);
|
||||
var segment2 = two.AsSpan(start2, j - start2);
|
||||
|
||||
// The two sides are at segments of different kinds — digits against letters. rpm calls the
|
||||
// numeric one newer, always, on the reasoning that a version that has started counting is further
|
||||
// along than one still spelling. segment1 is never empty: its kind was chosen from its own first
|
||||
// character.
|
||||
if (segment2.IsEmpty)
|
||||
{
|
||||
return numeric ? 1 : -1;
|
||||
}
|
||||
|
||||
if (numeric)
|
||||
{
|
||||
segment1 = segment1.TrimStart('0');
|
||||
segment2 = segment2.TrimStart('0');
|
||||
|
||||
// Longer, once the zeros are gone, is larger. Compared before the characters are, because a
|
||||
// string comparison would call "9" larger than "10".
|
||||
if (segment1.Length != segment2.Length)
|
||||
{
|
||||
return segment1.Length > segment2.Length ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
var ordering = segment1.CompareTo(segment2, StringComparison.Ordinal);
|
||||
|
||||
return ordering == 0 ? null : ordering > 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
/// <summary>Advances over one run of digits, or one run of letters.</summary>
|
||||
private static void Take(string value, ref int at, bool numeric)
|
||||
{
|
||||
while (at < value.Length
|
||||
&& (numeric ? char.IsAsciiDigit(value[at]) : char.IsAsciiLetter(value[at])))
|
||||
{
|
||||
at++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether this character takes part in the comparison rather than dividing it.</summary>
|
||||
private static bool IsVersionCharacter(char value) =>
|
||||
char.IsAsciiLetterOrDigit(value) || value == '~' || value == '^';
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Runtime.Versioning;
|
||||
using DodoSSH.Client.Session;
|
||||
using Velopack;
|
||||
using Velopack.Sources;
|
||||
@@ -31,11 +32,18 @@ internal static class UpdateChannels
|
||||
/// </remarks>
|
||||
internal static IUpdateChannel ForThisMachine()
|
||||
{
|
||||
// Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches
|
||||
// this too. Velopack has a Linux path — AppImage — but this repository does not build one, so a
|
||||
// Linux build is a checkout somebody ran, and handing it an UpdateManager would have it poll a
|
||||
// feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future
|
||||
// AppImage an addition here rather than a thing that silently already half-happened.
|
||||
// ◆ Linux is answered first and separately, because it is not a Velopack platform at all.
|
||||
//
|
||||
// This used to be the place that said so — a negation over the two packaged platforms, with a note
|
||||
// that a future AppImage should be an addition here rather than something that had silently
|
||||
// half-happened. It is an addition, and it is not an AppImage: the Linux head ships as an RPM and
|
||||
// is updated by the machine's own package manager, so its channel fetches and hands off rather
|
||||
// than replacing anything. See LinuxUpdateChannel and ADR 0013 rule 11.
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return LinuxChannel();
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
|
||||
{
|
||||
return new UnavailableUpdateChannel();
|
||||
@@ -57,6 +65,35 @@ internal static class UpdateChannels
|
||||
return new UnavailableUpdateChannel();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
|
||||
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
|
||||
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
|
||||
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
|
||||
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
|
||||
/// out.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
|
||||
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
|
||||
/// differently. The install root is a constant in two places — here and the packaging script — and
|
||||
/// they are named in each other's comments.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("linux")]
|
||||
private static IUpdateChannel LinuxChannel()
|
||||
{
|
||||
var root = DesktopChannel.IsNightly ? "/opt/DodoSSH.Nightly" : "/opt/DodoSSH";
|
||||
|
||||
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
|
||||
|
||||
return string.Equals(here, root, StringComparison.Ordinal)
|
||||
? new LinuxUpdateChannel(DesktopChannel.Name)
|
||||
: new UnavailableUpdateChannel();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -167,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 />
|
||||
@@ -284,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 />
|
||||
@@ -298,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);
|
||||
}
|
||||
@@ -9,17 +9,26 @@ namespace DodoSSH.Client.App.Platform;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware,
|
||||
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
|
||||
/// keeps asking for the passphrase — which is the honest answer rather than a degraded one.
|
||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware
|
||||
/// gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the passphrase — which is
|
||||
/// the honest answer rather than a degraded one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Both real stores are asked whether they work rather than told that they do.</b> Each
|
||||
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and deleting it — because on
|
||||
/// both platforms the provider is present and reports itself present on machines where creating a key
|
||||
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, and on macOS also every
|
||||
/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would
|
||||
/// mean each of those discovering the truth at the moment somebody tried to unlock.
|
||||
/// <b>Every real store is asked whether it works rather than told that it does.</b> Each
|
||||
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and discarding it — because on
|
||||
/// all three platforms the provider is present and reports itself present on machines where creating a key
|
||||
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, on macOS also every unsigned
|
||||
/// development build since enclave keys need a signing identity, and on Linux the ordinary case of a
|
||||
/// working TPM whose device node this user may not open. Inferring from the OS would mean each of those
|
||||
/// discovering the truth at the moment somebody tried to unlock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The three are not equally strong, and the ordering here does not imply they are.</b> Windows and
|
||||
/// macOS put the consent gesture inside the platform — a secure desktop and a system Touch ID sheet — so
|
||||
/// the process asking never sees the secret. The Linux store's PIN is typed into a window this
|
||||
/// application draws, and only the <em>checking</em> is the TPM's. That is a real difference, it is
|
||||
/// recorded in ADR 0007 rather than smoothed over, and it is why <see cref="LinuxDeviceKeyStore"/> is
|
||||
/// tried last rather than first among platforms that could in principle overlap.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
||||
@@ -33,9 +42,20 @@ namespace DodoSSH.Client.App.Platform;
|
||||
public static class DesktopDeviceKeyStores
|
||||
{
|
||||
/// <summary>The best store this machine supports.</summary>
|
||||
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
|
||||
/// <param name="paths">Where a stored key lives.</param>
|
||||
/// <param name="askForPin">
|
||||
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
|
||||
/// raises its own dialog and this process is not in the conversation. A caller with no way to draw one
|
||||
/// may pass a delegate answering null, which makes the Linux store decline every unlock; that is the
|
||||
/// right shape for a headless host and the wrong one for the application, which supplies a real dialog
|
||||
/// in <c>App.axaml.cs</c>.
|
||||
/// </param>
|
||||
public static IDeviceKeyStore ForThisMachine(
|
||||
ClientPaths paths,
|
||||
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(askForPin);
|
||||
|
||||
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||
{
|
||||
@@ -47,6 +67,11 @@ public static class DesktopDeviceKeyStores
|
||||
return new MacDeviceKeyStore(paths);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux() && LinuxDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new LinuxDeviceKeyStore(paths, askForPin);
|
||||
}
|
||||
|
||||
return new UnavailableDeviceKeyStore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,28 @@
|
||||
Showing the last terminal's pane would be a lie, and showing nothing reads as the application having
|
||||
broken, so this says which machine, as whom, and how far along it is.
|
||||
|
||||
── The step list, and why it is amber ───────────────────────────────────────────────────────────────
|
||||
"How far along it is" used to be one line of prose that never changed after the tab was created, which
|
||||
made every slow connection look exactly like every hung one. It is now the five steps of actually
|
||||
getting there, each lit at the moment the handshake reports it — see SshConnectionPhase, which names
|
||||
only the boundaries a client can genuinely observe. A connection that stops therefore stops on a named
|
||||
row, and "the host key is being checked" stops being the same screen as "the host is not answering".
|
||||
|
||||
Amber for the step in flight, and that is the palette's rule rather than an exception to it. Green is
|
||||
what is true and purple is what you can press; a step still happening is neither, and it is precisely
|
||||
the caveat-worth-reading amber exists for — see the remark above Warn in Palette.axaml. Steps behind it
|
||||
go green as they become true, and the one a refusal landed on goes red. Nothing on the list is drawn in
|
||||
the accent, because there is nothing on it to press.
|
||||
|
||||
Nothing here animates, which is the argument the transfer strip makes for its own track in
|
||||
TransfersScreen.axaml, arriving at a screen with more reason to want a spinner. A spinner is furniture
|
||||
invented to fill a state nobody measured; these steps are measured, so the track fills to what has
|
||||
actually finished and then waits there. Waiting is what waiting looks like.
|
||||
|
||||
The list is drawn for both states rather than once per state. A refused connection has the same five
|
||||
rows and the same track — the difference is only that one row is red and the track stops — and drawing
|
||||
it twice would be two templates to keep identical for the sake of a colour.
|
||||
|
||||
It obeys the occlusion rule the whole window obeys: this is Avalonia-drawn content in the WebView's own
|
||||
rectangle, so the shell collapses the terminal while it is up. IsTerminalShowing and IsConnectingShowing
|
||||
are exclusive by construction — a selected tab either has a session or it does not — which is what makes
|
||||
@@ -24,29 +46,138 @@
|
||||
can be laid out by a test: WebView2's adapter refuses the headless session's thread.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
|
||||
<!--
|
||||
A rule per lit state over one quiet default, so that the pending weight is stated once and each state
|
||||
that differs from it is the one line that says how.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepcaption">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The marker beside each caption. Fixed width and centred, because four different characters on a
|
||||
ragged left edge is a list that looks broken; see ConnectionStepViewModel.Mark for which they are.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepmark">
|
||||
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Width" Value="14" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The track over the list. Amber while the attempt is alive and red once it is not, so that the bar says
|
||||
the same thing as the row it stopped on rather than staying the colour of something still being waited
|
||||
for. Chip underneath, matching the transfer strip's track.
|
||||
-->
|
||||
<Style Selector="ProgressBar.steptrack">
|
||||
<Setter Property="Height" Value="5" />
|
||||
<Setter Property="MinHeight" Value="5" />
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="ProgressBar.steptrack.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
</UserControl.Styles>
|
||||
|
||||
<Panel>
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14" MaxWidth="460"
|
||||
<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>
|
||||
|
||||
<!--
|
||||
Two states, deliberately different. Waiting is an accent line under the host's name; a refusal is
|
||||
the reason, in the palette's red, because it is the only place the reason will be after the user
|
||||
navigates away from the screen that started the connection.
|
||||
Bound to StepsDone against StepCount rather than to a percentage: five steps and a maximum of five
|
||||
means the bar is a count of things that really finished, and the arithmetic that would turn it into
|
||||
a percentage is exactly the arithmetic that would start inventing one.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="12"
|
||||
Foreground="{StaticResource Accent}" HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" TextAlignment="Center"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}" HorizontalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="7" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="stepmark"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Mark}" />
|
||||
<TextBlock Classes="stepcaption mono"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Caption}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
A refusal is the reason, in the palette's red, because it is the only place the reason will be after
|
||||
the user navigates away from the screen that started the connection. It sits under the list rather
|
||||
than replacing it: which row it stopped on is half the answer and the sentence is the other half.
|
||||
-->
|
||||
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
|
||||
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" TextAlignment="Center"
|
||||
@@ -62,14 +193,36 @@
|
||||
handshake that finishes afterwards is adopted rather than dropped — see
|
||||
MainWindowViewModel.CloseTabAsync. Two buttons rather than one with a converted label, because the
|
||||
two are different decisions and only one of them abandons something still running.
|
||||
-->
|
||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="GIVE UP"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
|
||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="CLOSE TAB"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
Beside each, the logs. The step list is this attempt and the log is every other one, which is the
|
||||
question both a connection taking too long and a connection just refused actually raise — has this
|
||||
machine ever worked. It is the ordinary rail destination reached the ordinary way rather than a
|
||||
second log grown inside this card, and leaving by it does not abandon the handshake: the tab stays
|
||||
in the strip and the card is still here on the way back.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||
<!--
|
||||
TRY AGAIN rather than RECONNECT, because nothing was ever connected: this tab is a handshake that
|
||||
was refused. It runs the same command the disconnect strip's RECONNECT does — the vault's own
|
||||
connect path, into this same tab — and it is worth having here for the reason the commonest
|
||||
refusal is worth retrying at all: a host that was not up yet is a host that will be in a minute.
|
||||
|
||||
Disabled rather than hidden when there is nothing to dial, exactly as the strip's is. See
|
||||
MainWindowViewModel.CanReconnectSelectedTab.
|
||||
-->
|
||||
<Button Classes="ghost" Content="TRY AGAIN" Command="{Binding ReconnectTabCommand}"
|
||||
CommandParameter="{Binding SelectedTab}"
|
||||
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
|
||||
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
|
||||
CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
<Button Classes="ghost" Content="CLOSE TAB" Command="{Binding CloseTabCommand}"
|
||||
CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="DodoSSH.Client.App.Views.DevicePinWindow"
|
||||
Width="420" SizeToContent="Height"
|
||||
CanResize="False" ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource Surface}"
|
||||
Title="DodoSSH">
|
||||
|
||||
<!--
|
||||
The PIN that unseals this machine's device key, on Linux and nowhere else.
|
||||
|
||||
◆ A WINDOW, WHERE EVERY OTHER QUESTION THIS APPLICATION ASKS IS A CARD.
|
||||
|
||||
ConfirmDeleteCard, HostKeyCard and the rest are overlays bound to view-model state, and that is the
|
||||
right shape for a question about the vault: the shell owns the moment and the Android head shows the
|
||||
same one. This is not that kind of question. It belongs to a platform integration — LinuxDeviceKeyStore
|
||||
— and the shell it would otherwise live in is shared with the phone, which has a fingerprint and must
|
||||
never grow a PIN box. Keeping it here keeps DodoSSH.Client.Shell platform-neutral, which is the
|
||||
property IDeviceKeyStore exists to protect.
|
||||
|
||||
◆ AND IT IS DELIBERATELY PLAIN.
|
||||
|
||||
Windows and macOS draw their equivalent themselves — a secure desktop and a system sheet — and this one
|
||||
cannot be either. Dressing it up to look like a system dialog would be teaching people that a
|
||||
convincing DodoSSH PIN box is a system dialog, which is the phishing this platform's design cannot
|
||||
prevent and should not rehearse. It says what it is, it names the application, and it stops there.
|
||||
ADR 0007 records the difference rather than smoothing it over.
|
||||
-->
|
||||
|
||||
<StackPanel Margin="20" Spacing="12">
|
||||
|
||||
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
|
||||
x:Name="Explanation" />
|
||||
|
||||
<!--
|
||||
RevealPassword is off and there is no eye button. A PIN is short enough to retype and this box is
|
||||
open on a screen somebody may be sharing.
|
||||
-->
|
||||
<TextBox x:Name="Pin" PasswordChar="•" PlaceholderText="PIN" FontSize="14" />
|
||||
|
||||
<!--
|
||||
Only when choosing. A mistyped PIN at registration is not recoverable in any useful sense: the key is
|
||||
sealed to it, and the only way back is to withdraw the device and register again. Asking twice costs
|
||||
one box.
|
||||
-->
|
||||
<TextBox x:Name="Confirm" PasswordChar="•" PlaceholderText="PIN again" FontSize="14"
|
||||
IsVisible="False" />
|
||||
|
||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||
x:Name="Problem" IsVisible="False" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||
<Button x:Name="Cancel" Content="CANCEL" />
|
||||
<Button x:Name="Accept" Classes="accent" Content="UNLOCK" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</Window>
|
||||
@@ -0,0 +1,203 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Asks for the PIN that unseals this machine's device key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Raised by <see cref="LinuxDeviceKeyStore"/> through the delegate <c>App.axaml.cs</c> supplies, and by
|
||||
/// nothing else. See the markup for why this is a window rather than a card, and why it is plain.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cancelling is an ordinary answer, not an error.</b> Escape, the close button and CANCEL all resolve
|
||||
/// to null, which the store reads as "no PIN" and the unlock path answers with the passphrase screen. That
|
||||
/// is the same contract <c>IDeviceKeyStore.TryLoadAsync</c> documents for a declined fingerprint.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class DevicePinWindow : Window
|
||||
{
|
||||
/// <summary>
|
||||
/// The shortest PIN this will accept, and the reason it is this short rather than longer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Four, because the TPM counts wrong answers and stops — which is exactly the property that makes a
|
||||
/// short secret safe here and does not exist for anything kept in a file. A PIN long enough to be safe
|
||||
/// without hardware counting would be a second passphrase, and somebody asked for a second passphrase
|
||||
/// reuses the first.
|
||||
/// </remarks>
|
||||
private const int ShortestPin = 4;
|
||||
|
||||
private TaskCompletionSource<string?>? answer;
|
||||
|
||||
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
|
||||
public DevicePinWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
Accept.Click += OnAccept;
|
||||
Cancel.Click += OnCancel;
|
||||
KeyDown += OnKeyDown;
|
||||
Pin.KeyDown += OnKeyDown;
|
||||
Confirm.KeyDown += OnKeyDown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the prompt over the main window and answers with what was typed, or null.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Marshalled onto the UI thread here rather than at the call site.</b> The store is called from
|
||||
/// wherever the unlock happens to be running — a background task on launch, a command handler on a
|
||||
/// button — and none of those should have to know that this ends in a window. Avalonia throws rather
|
||||
/// than misbehaving if it is wrong, which would turn a device unlock into a crash.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A missing owner answers null rather than showing an ownerless window. It means the main window is
|
||||
/// gone or not up yet, and a modal dialog with nothing behind it is a prompt the user cannot place —
|
||||
/// which is precisely the shape of the thing this application must not train people to accept.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="owner">The window to sit over.</param>
|
||||
/// <param name="purpose">Whether a PIN is being chosen or entered.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public static ValueTask<string?> AskAsync(
|
||||
Func<Window?> owner,
|
||||
DevicePinPurpose purpose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(owner);
|
||||
|
||||
return new ValueTask<string?>(
|
||||
Dispatcher.UIThread.InvokeAsync(
|
||||
async () =>
|
||||
{
|
||||
if (owner() is not { } parent)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var window = new DevicePinWindow();
|
||||
|
||||
window.Prepare(purpose);
|
||||
|
||||
using var registration = cancellationToken.Register(window.Close);
|
||||
|
||||
return await window.ShowFor(parent).ConfigureAwait(true);
|
||||
}));
|
||||
}
|
||||
|
||||
private void Prepare(DevicePinPurpose purpose)
|
||||
{
|
||||
var choosing = purpose == DevicePinPurpose.Choosing;
|
||||
|
||||
Heading.Text = choosing ? "Choose a PIN for this computer" : "Enter your DodoSSH PIN";
|
||||
|
||||
Explanation.Text = choosing
|
||||
? "Your device key is sealed into this computer's TPM. The PIN is checked by the TPM itself, "
|
||||
+ "which stops answering after a few wrong tries — so a short one is enough, and it only "
|
||||
+ "ever works on this computer."
|
||||
: "This unlocks the key sealed into this computer's TPM. Cancel to use your passphrase "
|
||||
+ "instead.";
|
||||
|
||||
Confirm.IsVisible = choosing;
|
||||
Accept.Content = choosing ? "SET PIN" : "UNLOCK";
|
||||
}
|
||||
|
||||
private async Task<string?> ShowFor(Window parent)
|
||||
{
|
||||
answer = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// Resolves the pending answer however the window goes away — the close button, Escape, or the
|
||||
// cancellation registration above. Without it a closed window leaves an await that never returns,
|
||||
// and the unlock path hangs rather than falling back to the passphrase.
|
||||
Closed += (_, _) => answer.TrySetResult(null);
|
||||
|
||||
Pin.Focus();
|
||||
|
||||
await ShowDialog(parent).ConfigureAwait(true);
|
||||
|
||||
return await answer.Task.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private void OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape:
|
||||
Complete(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Enter:
|
||||
Submit();
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAccept(object? sender, RoutedEventArgs e) => Submit();
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Complete(null);
|
||||
|
||||
/// <summary>
|
||||
/// Checks what was typed and either answers with it or says what is wrong.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both checks are about a PIN being <em>chosen</em>. Nothing validates a PIN being entered, because
|
||||
/// the only thing that can judge that is the TPM — refusing a four-character entry here because the
|
||||
/// stored one was six would be this process deciding something it has no way to know.
|
||||
/// </remarks>
|
||||
private void Submit()
|
||||
{
|
||||
var pin = Pin.Text ?? string.Empty;
|
||||
|
||||
if (!Confirm.IsVisible)
|
||||
{
|
||||
if (pin.Length > 0)
|
||||
{
|
||||
Complete(pin);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (pin.Length < ShortestPin)
|
||||
{
|
||||
Show($"A PIN needs at least {ShortestPin} characters.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(pin, Confirm.Text ?? string.Empty, StringComparison.Ordinal))
|
||||
{
|
||||
Show("Those two do not match.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Complete(pin);
|
||||
}
|
||||
|
||||
private void Show(string problem)
|
||||
{
|
||||
Problem.Text = problem;
|
||||
Problem.IsVisible = true;
|
||||
}
|
||||
|
||||
private void Complete(string? pin)
|
||||
{
|
||||
answer?.TrySetResult(pin);
|
||||
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -584,6 +584,56 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Making a credential without leaving the host. The moment one is wanted is this one: somebody
|
||||
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
||||
sending them to the other screen to add it would lose the half-typed host they are standing
|
||||
in. Same argument as the new-tag box further down, same immediate write, same honest
|
||||
consequence — the credential stays if this editor is cancelled, because a host can only name
|
||||
an id that exists.
|
||||
|
||||
A button beside the picker rather than an entry inside it. Every row of that list is a
|
||||
binding the host can have; "make a new one" is an action, and as an entry it would sit in the
|
||||
box afterwards describing a state no host can be in.
|
||||
-->
|
||||
<Button Classes="ghost" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
|
||||
FontSize="10.5" Height="28" Padding="10,0"
|
||||
IsVisible="{Binding !IsAddingEditorCredential}"
|
||||
Command="{Binding BeginEditorCredentialCommand}"
|
||||
ToolTip.Tip="Adds a credential to the keychain and binds this host to it" />
|
||||
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
|
||||
IsVisible="{Binding IsAddingEditorCredential}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="label" Text="NEW CREDENTIAL" FontSize="10" />
|
||||
<TextBox Text="{Binding EditorNewCredentialLabel}" PlaceholderText="name" Height="36" />
|
||||
<!--
|
||||
Optional, and what makes a credential worth being its own item: one account on twenty
|
||||
machines is rotated in one place. Left blank, this host's own username is used.
|
||||
-->
|
||||
<TextBox Text="{Binding EditorNewCredentialUsername}" Height="36"
|
||||
PlaceholderText="username (blank: use this host's own)" />
|
||||
<!-- Masked, on the reasoning the keychain's own password box carries. -->
|
||||
<TextBox Text="{Binding EditorNewCredentialPassword}" PlaceholderText="password"
|
||||
PasswordChar="•" Height="36">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorCredentialCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBox Text="{Binding EditorNewCredentialNotes}" PlaceholderText="notes"
|
||||
AcceptsReturn="True" Height="44" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Added to the keychain as soon as you press ADD, so it stays even if you cancel this host. Renaming and deleting are on the keychain screen." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="ADD"
|
||||
Command="{Binding AddEditorCredentialCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding CancelEditorCredentialCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
|
||||
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
||||
|
||||
@@ -98,6 +98,18 @@
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<!--
|
||||
── 26 DOWN EACH SIDE, the same inset Keychain, Snips, Logs and Pins all take. ──────────────────────
|
||||
Those four say it once, as Margin="26" on their own root; this screen repeats it on each of the four
|
||||
rows below, and it has to. The board's ScrollViewer is the last row and is deliberately full-bleed, so
|
||||
that its scrollbar rides the pane's own edge rather than floating 26 pixels inside it — a root margin
|
||||
would inset the bar with everything else. It would also inset the drawer in the second column, which
|
||||
draws its own edge and wants none.
|
||||
|
||||
It was 16 and 20 until this pass, which put the Hosts header a visible step left of and above every
|
||||
other screen's. Four numbers rather than one is the cost of the two exceptions above; changing one of
|
||||
them means changing all four.
|
||||
-->
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<!--
|
||||
@@ -107,7 +119,7 @@
|
||||
buttons over a board of forty is a pair whose subject the user has to work out. The group's own
|
||||
Edit/Move/Delete sit on its own heading's menu for the same reason.
|
||||
-->
|
||||
<Grid Grid.Row="0" Margin="16,20,16,16" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
|
||||
<Grid Grid.Row="0" Margin="26,26,26,16" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
|
||||
|
||||
<TextBlock Grid.Column="0" Text="Hosts" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
@@ -219,7 +231,7 @@
|
||||
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
|
||||
who has found this box should know about the one that also connects on Enter.
|
||||
-->
|
||||
<Border Grid.Row="1" Margin="16,0,16,16">
|
||||
<Border Grid.Row="1" Margin="26,0,26,16">
|
||||
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
|
||||
@@ -232,7 +244,7 @@
|
||||
one of them sits here, above the board, rather than laid over it: a card over the cards would hide
|
||||
the very ticks or the very group it is asking about.
|
||||
-->
|
||||
<StackPanel Grid.Row="2" Margin="16,0,16,12" Spacing="10">
|
||||
<StackPanel Grid.Row="2" Margin="26,0,26,12" Spacing="10">
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
|
||||
@@ -434,7 +446,7 @@
|
||||
HostsScreen.axaml.cs.
|
||||
-->
|
||||
<ScrollViewer Grid.Row="3" x:Name="Scroll" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="16,0,16,20" Spacing="16">
|
||||
<StackPanel Margin="26,0,26,26" Spacing="16">
|
||||
|
||||
<!--
|
||||
Named because it is where keyboard focus lands when the terminal gives it back, and because
|
||||
@@ -445,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>
|
||||
@@ -460,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
|
||||
@@ -557,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}" />
|
||||
@@ -588,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">
|
||||
|
||||
@@ -690,7 +746,7 @@
|
||||
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
|
||||
IsVisible="{Binding HasPins}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="3">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
||||
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
||||
<TextBlock Classes="mono" Text="{Binding PinCount}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -148,12 +148,26 @@
|
||||
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
||||
<views:SessionHeader Grid.Row="0"
|
||||
OpenLabel="Open terminal"
|
||||
OpenCommand="{Binding OpenTerminalForFilesHostCommand}"
|
||||
EmptyText="{Binding Transfers.Status}" />
|
||||
<views:TransfersScreen Grid.Row="1" DataContext="{Binding Transfers}" />
|
||||
<!--
|
||||
v5c-4: two rows rather than three. The 60-pixel host header that used to sit above this
|
||||
screen is gone; the address and the "Open terminal" button it carried are in the
|
||||
sidebar now — see SessionSidebar.axaml — and the pane keeps the height. Its third
|
||||
binding, the Transfers.Status line it printed while no host was open, is not moved
|
||||
either: TransfersScreen draws that same string itself, both in its own empty state and
|
||||
beside the remote pane's DISCONNECT once something is open.
|
||||
-->
|
||||
<!--
|
||||
Three rows since the disconnect strip arrived, and it is the same control the terminal
|
||||
surface draws — see SessionEndedBar.axaml, which resolves which surface it is about
|
||||
through the shell rather than through a binding of its own. Auto and empty except while
|
||||
the file session has been lost, so an ordinary browse pays nothing for it.
|
||||
|
||||
Note the DataContext: this row is the shell's rather than the transfers screen's, which
|
||||
is why the strip sits here and not inside TransfersScreen.axaml.
|
||||
-->
|
||||
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
|
||||
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
|
||||
<views:SessionEndedBar Grid.Row="1" />
|
||||
<views:SessionStatusBar Grid.Row="2" />
|
||||
</Grid>
|
||||
<!--
|
||||
@@ -268,14 +282,23 @@
|
||||
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
||||
<views:SessionHeader Grid.Row="0"
|
||||
OpenLabel="Open SFTP"
|
||||
OpenCommand="{Binding SelectFilesHostCommand}"
|
||||
OpenCommandParameter="{Binding SelectedTab}"
|
||||
EmptyText="no terminals open · press + or Ctrl+K, or choose a host and press Connect" />
|
||||
<!--
|
||||
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
|
||||
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
|
||||
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
|
||||
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
||||
answers for itself in the tab row's own "+" and in the connecting card below.
|
||||
-->
|
||||
<!--
|
||||
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
|
||||
and empty except while the selected tab's shell has stopped, so it costs the terminal
|
||||
nothing in the ordinary case — and when it is up it takes its 34 pixels off the pane rather
|
||||
than drawing over it, which is the only way Avalonia content can sit near the renderer at
|
||||
all. See SessionEndedBar.axaml.
|
||||
-->
|
||||
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
|
||||
|
||||
<Panel Grid.Row="1" Background="{StaticResource Pane}">
|
||||
<Panel Grid.Row="0" Background="{StaticResource Pane}">
|
||||
|
||||
<!--
|
||||
The other thing that can be in the terminal's rectangle: a tab whose session does not
|
||||
@@ -300,6 +323,7 @@
|
||||
|
||||
</Panel>
|
||||
|
||||
<views:SessionEndedBar Grid.Row="1" />
|
||||
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
|
||||
</Grid>
|
||||
<!--
|
||||
|
||||
@@ -54,6 +54,21 @@ internal sealed partial class MainWindow : Window
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Colours the system-drawn frame the moment there is a handle to colour it on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>OnOpened</c> and not the constructor: the window has no platform handle until it is shown, and
|
||||
/// <see cref="NativeWindowFrame"/> does nothing without one. See that class for what the frame is and
|
||||
/// why <c>BorderOnly</c> still has one.
|
||||
/// </remarks>
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
NativeWindowFrame.MatchTo(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the Linux backend for the one mode it can actually draw inside this window.
|
||||
/// </summary>
|
||||
@@ -268,6 +283,7 @@ internal sealed partial class MainWindow : Window
|
||||
if (shell is { } previous)
|
||||
{
|
||||
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
|
||||
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||
}
|
||||
|
||||
@@ -286,6 +302,7 @@ internal sealed partial class MainWindow : Window
|
||||
wasUnlocked = viewModel.IsUnlocked;
|
||||
|
||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||
viewModel.PropertyChanged += OnShellPropertyChanged;
|
||||
}
|
||||
|
||||
@@ -298,6 +315,15 @@ internal sealed partial class MainWindow : Window
|
||||
/// </remarks>
|
||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||
|
||||
/// <remarks>
|
||||
/// The same call for a session that was already open and has just been typed into from the sidebar —
|
||||
/// see <see cref="MainWindowViewModel.TerminalFocusRequested"/>. Posted like every other path here,
|
||||
/// although nothing was revealed this turn: the post also re-checks that a terminal is still showing,
|
||||
/// which is what keeps this from stealing the keyboard if the insert landed the user on the snippets
|
||||
/// screen instead.
|
||||
/// </remarks>
|
||||
private void OnTerminalFocusRequested(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||
|
||||
/// <remarks>
|
||||
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
|
||||
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Paints the frame Windows still draws around a <c>BorderOnly</c> window in the application's own
|
||||
/// colour, so the top edge stops reading as a leftover system titlebar.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The symptom this exists for:</b> a pale strip across the very top of the window, a few pixels
|
||||
/// tall and plainly not part of the application — most obvious on a machine with "show accent colour
|
||||
/// on title bars and window borders" turned on, where it comes out blue against a near-black shell.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is not <c>TitleBar.axaml</c> leaking and it is not a margin. It is DWM, and the reason it is
|
||||
/// there is visible in Avalonia's own Win32 backend: <c>WindowImpl.UpdateWindowProperties</c> gives a
|
||||
/// <see cref="WindowDecorations.BorderOnly"/> window <c>WS_BORDER | WS_THICKFRAME</c> and then calls
|
||||
/// <c>DwmExtendFrameIntoClientArea</c> with one-pixel margins on all four sides. So the compositor
|
||||
/// owns a hairline of every edge of this window, and it fills that hairline with the system's caption
|
||||
/// and border colours — which are chosen by the user's personalisation settings and have no reason to
|
||||
/// resemble <c>CanvasColor</c>. The window is the wrong place to look for the pixels; they were never
|
||||
/// painted by anything in this tree.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The fix is to tell DWM what colour to use rather than to try to cover it. <c>DWMWA_BORDER_COLOR</c>
|
||||
/// and <c>DWMWA_CAPTION_COLOR</c> arrived in Windows 11 21H2 and are exactly that; both are set to the
|
||||
/// window's own background, so the hairline still exists — the resize grip is on it, and the drop
|
||||
/// shadow hangs off it — and simply cannot be seen. Deliberately <em>not</em> <c>DWMWA_COLOR_NONE</c>,
|
||||
/// which removes the border outright: on a dark desktop that leaves a near-black window with no edge
|
||||
/// at all, which trades one visual defect for another.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Windows 10 gets the dark-mode attribute and nothing else, and that is the whole of what is
|
||||
/// available there: the two colour attributes are unsupported, <c>DwmSetWindowAttribute</c> answers
|
||||
/// <c>E_INVALIDARG</c>, and the calls do nothing. Hence the ignored return values — every attribute
|
||||
/// here is an improvement where it lands and a no-op where it does not, so there is nothing for a
|
||||
/// caller to handle and nothing worth logging on a path that runs once at startup.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class NativeWindowFrame
|
||||
{
|
||||
/// <summary>Windows 11 21H2 and later: the colour of the frame border.</summary>
|
||||
private const int BorderColorAttribute = 34;
|
||||
|
||||
/// <summary>Windows 11 21H2 and later: the colour of the caption, including the extended frame.</summary>
|
||||
private const int CaptionColorAttribute = 35;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 10 1903 and later: draw the frame in the dark palette.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Redundant on Windows 11, where the two colour attributes above name the colours outright, and it
|
||||
/// is set anyway because it is the only one of the three that Windows 10 honours. The build before
|
||||
/// 1903 used attribute 19 for this; that is not chased here, because a border on an OS release that
|
||||
/// left support in 2020 is not worth a second interop call.
|
||||
/// </remarks>
|
||||
private const int DarkModeAttribute = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Matches <paramref name="window"/>'s system-drawn frame to the colour it paints itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Call once the window has a handle — <c>OnOpened</c> is the first such moment. Calling earlier
|
||||
/// finds no platform handle and silently does nothing, which is the defect this replaced: the strip
|
||||
/// is only visible once the window is on screen, so a call that ran too early looks like a fix that
|
||||
/// does not work rather than a fix that never ran.
|
||||
/// </remarks>
|
||||
internal static void MatchTo(Window window)
|
||||
{
|
||||
// Every attribute below is a DWM one, and DWM is Windows. Elsewhere the frame is drawn by the
|
||||
// platform's own compositor and there is nothing here to say to it.
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.TryGetPlatformHandle()?.Handle is not { } handle || handle == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Set(handle, DarkModeAttribute, 1);
|
||||
|
||||
// The window's own Background rather than a named resource, so the frame cannot drift from the
|
||||
// canvas when the palette moves. A brush that is not solid — a gradient, or nothing set at all —
|
||||
// has no single colour to match, and leaving the system's own is better than inventing one.
|
||||
if (window.Background is not ISolidColorBrush { Color: var canvas })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var reference = ColorRef(canvas);
|
||||
Set(handle, BorderColorAttribute, reference);
|
||||
Set(handle, CaptionColorAttribute, reference);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets one integer-valued DWM attribute, and discards the answer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The discard is the point of this method existing rather than being three call sites. Every
|
||||
/// attribute here is unsupported on some Windows this application runs on, and unsupported means
|
||||
/// <c>E_INVALIDARG</c> and no change — which is the intended outcome on that OS, not a failure, so
|
||||
/// there is nothing for the caller to do with the <c>HRESULT</c> and nothing worth logging once at
|
||||
/// startup. Written once, with the reasoning, rather than left implicit at each call.
|
||||
/// </remarks>
|
||||
private static void Set(IntPtr window, int attribute, int value) =>
|
||||
_ = DwmSetWindowAttribute(window, attribute, ref value, sizeof(int));
|
||||
|
||||
/// <summary>
|
||||
/// Packs <paramref name="color"/> into a Win32 <c>COLORREF</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>0x00BBGGRR</c> — blue in the high byte, not red, and the alpha byte must be zero. Getting the
|
||||
/// order wrong produces a plausible-looking wrong colour rather than an error, which is the kind of
|
||||
/// bug that survives a glance at the window.
|
||||
/// </remarks>
|
||||
private static int ColorRef(Color color) => color.R | (color.G << 8) | (color.B << 16);
|
||||
|
||||
/// <remarks>
|
||||
/// <c>DllImport</c> rather than <c>LibraryImport</c>, for the reason
|
||||
/// <see cref="NativeKeyboardFocus"/> gives at its own P/Invoke: the generated form needs
|
||||
/// <c>AllowUnsafeBlocks</c> across a project that handles key material, and this signature is
|
||||
/// blittable, so there is no marshalling for it to improve.
|
||||
/// </remarks>
|
||||
#pragma warning disable SYSLIB1054
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(IntPtr window, int attribute, ref int value, int size);
|
||||
#pragma warning restore SYSLIB1054
|
||||
}
|
||||
@@ -40,8 +40,9 @@
|
||||
Both are still one click away; see the popover below the user chip. The chip itself carries the signed-
|
||||
in identity this application actually has — a display name and, where the server sent one, an email —
|
||||
which is also new: the titlebar drew an account name and a vault chip before this pass and does not any
|
||||
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the deviations this rail keeps on
|
||||
purpose: Pins, which the mock has no screen for at all, and the S3 segment above.
|
||||
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the one deviation this rail still
|
||||
keeps on purpose: the S3 segment above. Pins was the other, and it is gone — see the remark where that
|
||||
row used to sit, between Keys and Snips.
|
||||
|
||||
Buttons rather than a TabStrip or a ListBox, still, for the reason the v3 remark gave: all three hold
|
||||
the selection themselves, so a click would move the highlight before the shell decided anything, and a
|
||||
@@ -128,19 +129,16 @@
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
KEPT — the mock has no screen for approved host keys at all; see the file-level remark. push_pin
|
||||
is the same codepoint HostsScreen.axaml already draws for a host's own pin badge, reused rather
|
||||
than picked afresh so the one concept reads as one glyph everywhere it appears.
|
||||
◆ NO Pins ROW. The pins screen is still here and still reached in one click — from "Host keys"
|
||||
on the Keys screen's own header, which is where a list of approved host keys belongs: they are
|
||||
keychain material, and that button was already the second way to reach them. Two rail rows away
|
||||
from each other, both landing on the same screen, is a rail that has to be read twice.
|
||||
|
||||
It is also the last of the rail's own deviations from the mock to go. The row was kept in v5b on
|
||||
the grounds that the design has no screen for approved host keys at all — see the file-level
|
||||
remark — which is true of the design and was never a reason for a rail entry once the keychain
|
||||
had a door to the same place.
|
||||
-->
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
||||
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Pins" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
@@ -191,17 +189,22 @@
|
||||
</Grid>
|
||||
|
||||
<FlyoutBase.AttachedFlyout>
|
||||
<Flyout Placement="TopEdgeAlignedLeft">
|
||||
<StackPanel Width="227" Spacing="8">
|
||||
<!--
|
||||
FlyoutPresenterClasses, because a Flyout's own panel is not in this markup's visual tree to be
|
||||
styled from here — see FlyoutPresenter.poppanel in App.axaml for what the class carries and why
|
||||
the shared popup rule was not simply widened to cover it.
|
||||
-->
|
||||
<Flyout Placement="TopEdgeAlignedLeft" FlyoutPresenterClasses="poppanel">
|
||||
<StackPanel Width="227" Spacing="4">
|
||||
|
||||
<!--
|
||||
The real email, when the server sent one — verified against MainWindowViewModel.Email rather
|
||||
than assumed, and simply absent from the popover when it has not. No " · Org" suffix: there
|
||||
is no organisation concept behind a vault, only the vault itself, which the rows below name.
|
||||
-->
|
||||
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1"
|
||||
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1" Margin="11,4,11,6"
|
||||
Foreground="{StaticResource TextGhost}"
|
||||
Text="{Binding Email}"
|
||||
Text="{Binding Email}" TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!--
|
||||
@@ -246,42 +249,36 @@
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderMid}" />
|
||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||
|
||||
<!--
|
||||
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
|
||||
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">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource Text}" />
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<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 TextGhost}" />
|
||||
</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 TextGhost}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderMid}" />
|
||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||
|
||||
<!--
|
||||
The existing sign-out flow, with its own confirm card — see
|
||||
@@ -291,7 +288,7 @@
|
||||
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource Text}" />
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
@@ -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"/>.
|
||||
|
||||
@@ -87,12 +87,27 @@
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Two readings stacked against the two on the left, and in the same order: what this row
|
||||
IS above, how it is reached below.
|
||||
|
||||
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
|
||||
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
|
||||
The palette searches every readable vault at once, so without it two hosts a team and a
|
||||
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
|
||||
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
|
||||
vault name would otherwise take its width out of the host name beside it.
|
||||
|
||||
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
||||
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
||||
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
||||
-->
|
||||
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
|
||||
HorizontalAlignment="Right" MaxWidth="150"
|
||||
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SessionEndedBar"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
── What a session says once it has stopped ──────────────────────────────────────────────────────────
|
||||
A strip between the pane and the status bar, shown only while the session in front of the user has
|
||||
ended. It names which ending it was, prints the sentence that came with it, and offers the one thing
|
||||
worth offering: dialling the machine again.
|
||||
|
||||
◆ ONE CONTROL ON BOTH SURFACES, and the shell is what resolves which it is drawn over. A terminal has
|
||||
two endings to tell apart and a keychain entry to check before it can offer a redial; a file session has
|
||||
one ending and already captured what it would dial. None of that is a decision a view should be making,
|
||||
so every binding here is a MainWindowViewModel property that has already asked which surface is showing
|
||||
— see SessionEndedText, CanReconnectSession and SessionEndedDismissLabel. The second button is even
|
||||
labelled from the shell, because CLOSE TAB and DISCONNECT are noticeably different acts and one vague
|
||||
word covering both would be the wrong kind of reuse.
|
||||
|
||||
◆ IT IS A ROW OF ITS OWN RATHER THAN SOMETHING DRAWN OVER THE PANE, and on the terminal surface that is
|
||||
not a style choice. The renderer is a native child view that composites above everything Avalonia draws,
|
||||
so a banner overlapping its rectangle would either be invisible or would have to collapse the terminal —
|
||||
and collapsing it is exactly wrong here, because the scrollback is what the user is reading. A dead
|
||||
shell's last words are usually why it died. So this takes its own Auto row and the pane simply gets 34
|
||||
fewer pixels while it is up. The file surface has no such constraint and takes the same row anyway,
|
||||
because a listing left on screen is worth the same as a scrollback: it is where the user was, and it is
|
||||
what makes RECONNECT mean something. See MainWindow.axaml for both placements.
|
||||
|
||||
◆ THE SENTENCE IS HERE AS WELL AS IN THE PANE ON PURPOSE. terminal.js writes the same string into the
|
||||
scrollback as a grey rule, which is right and is not enough: a machine that printed a page of shutdown
|
||||
messages on its way down has pushed that line off the screen, and a session that ended while the user
|
||||
was on another tab was never on screen at all. This copy does not scroll.
|
||||
|
||||
── the two endings, and why they are not one colour ─────────────────────────────────────────────────
|
||||
SHELL EXITED is grey. Somebody typed `exit`, or a login shell ended; nothing is wrong and an alarm
|
||||
would be noise. DISCONNECTED is the palette's red, because the connection went out from under a session
|
||||
that had not finished — a reboot, a network drop, an sshd that was killed — and it is the one of the two
|
||||
that means the machine may still be there and worth dialling. The dot beside the word says the same
|
||||
thing a second way, for anybody who cannot tell this design's red from its grey.
|
||||
|
||||
── the buttons ──────────────────────────────────────────────────────────────────────────────────────
|
||||
RECONNECT is disabled rather than hidden when there is nothing to dial — a tab typed in by hand, or one
|
||||
whose host has left the keychain — because the answer to "why can I not reconnect this" is better given
|
||||
by a control that is visibly refusing than by one that is not there. See
|
||||
MainWindowViewModel.CanReconnectSession, which is what draws that line on each surface, and
|
||||
ReconnectSessionAsync for what the button actually does: each surface's own connect path, back into the
|
||||
thing that stopped. On the file side that includes walking back to the directory the pane was showing,
|
||||
which is what makes it a reconnect rather than a connect.
|
||||
|
||||
The second button is the moment the other reasonable answer is "I have read it, take it away" — and
|
||||
hunting for a 16-pixel cross in the strip above is not the shape of that answer.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
|
||||
<Style Selector="TextBlock.endword">
|
||||
<Setter Property="FontFamily" Value="Montserrat" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="FontSize" Value="9.5" />
|
||||
<Setter Property="LetterSpacing" Value="1.2" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.endword.dropped">
|
||||
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||
</Style>
|
||||
|
||||
</UserControl.Styles>
|
||||
|
||||
<Border Height="34" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0">
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot" Width="7" Height="7" Classes.dropped="{Binding IsSessionDropped}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="endword" Classes.dropped="{Binding IsSessionDropped}"
|
||||
Text="{Binding SessionEndedText}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Trimmed rather than wrapped: this row is one line tall and the sentences it draws are short, but a
|
||||
transport error can hand over whatever the operating system called it, and a bar that grew to fit
|
||||
one would take the height out of the terminal above it.
|
||||
-->
|
||||
<SelectableTextBlock Grid.Column="1" Classes="mono" FontSize="11.5" Margin="16,0"
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding SessionEndedDetail}"
|
||||
ToolTip.Tip="{Binding SessionEndedDetail}" />
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<Button Classes="ghost" Content="RECONNECT"
|
||||
Command="{Binding ReconnectSessionCommand}"
|
||||
IsEnabled="{Binding CanReconnectSession, FallbackValue=False}"
|
||||
ToolTip.Tip="Opens this connection again, in place." />
|
||||
<Button Classes="ghost" Content="{Binding SessionEndedDismissLabel}"
|
||||
Command="{Binding DismissEndedSessionCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,15 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>What the session shell says once the session in front of the user has stopped.</summary>
|
||||
/// <remarks>
|
||||
/// No code of its own: the word, the sentence and both buttons are the shell's, including which surface
|
||||
/// they are about — see the markup. In its own file for the reason every other card here is: nothing inside
|
||||
/// <c>MainWindow</c> can be laid out by a test, because WebView2's adapter refuses the headless session's
|
||||
/// thread.
|
||||
/// </remarks>
|
||||
internal sealed partial class SessionEndedBar : UserControl
|
||||
{
|
||||
public SessionEndedBar() => InitializeComponent();
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SessionHeader"
|
||||
x:Name="Root"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
── v5b's session shell host header ──────────────────────────────────────────────────────────────────────
|
||||
60px, DeepChrome, atop the pane both the terminal and the SFTP surface hold. Per the design, minus the
|
||||
three deviations design-notes/v5b-fidelity-notes.md records: no OS label, no latency reading, no "Port
|
||||
forward" button — none of those are facts this application has.
|
||||
|
||||
◆ THE ONE FACT LEFT is the address, read off MainWindowViewModel.SessionAddress — which is already the
|
||||
surface-aware property, so this control asks no question about which screen it is on. What differs
|
||||
between the two usages is only the cross-surface button: <see cref="OpenLabel"/>, <see cref="OpenCommand"/>
|
||||
and the empty-state copy, all handed in from MainWindow.axaml rather than branched on here.
|
||||
-->
|
||||
|
||||
<Border Height="60" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="24,0,20,0">
|
||||
|
||||
<!--
|
||||
The address, only while a session/host context is active — see SessionAddress's own remark for what
|
||||
"active" means on each surface. The empty state takes its place otherwise, in the idiom every other
|
||||
screen's own "nothing yet" sentence already uses: TextFaint, sentence case, no punctuation implying a
|
||||
form to fill in.
|
||||
-->
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold" FontSize="14"
|
||||
Foreground="{StaticResource AccentText}" VerticalAlignment="Center"
|
||||
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="{Binding #Root.EmptyText}" TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNullOrEmpty}}" />
|
||||
|
||||
<Button Grid.Column="1" Classes="headerghost"
|
||||
Content="{Binding #Root.OpenLabel}"
|
||||
Command="{Binding #Root.OpenCommand}"
|
||||
CommandParameter="{Binding #Root.OpenCommandParameter}" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -1,58 +0,0 @@
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The v5b session shell's host header: the address, and a ghost button that crosses to the other surface.
|
||||
/// See the remark at the top of SessionHeader.axaml.
|
||||
/// </summary>
|
||||
internal sealed partial class SessionHeader : UserControl
|
||||
{
|
||||
/// <summary>What the cross-surface ghost button says — "Open SFTP" or "Open terminal".</summary>
|
||||
internal static readonly StyledProperty<string?> OpenLabelProperty =
|
||||
AvaloniaProperty.Register<SessionHeader, string?>(nameof(OpenLabel));
|
||||
|
||||
/// <summary>What the cross-surface ghost button runs.</summary>
|
||||
/// <remarks>
|
||||
/// The terminal usage binds <c>SelectFilesHostCommand</c> with the selected tab as its parameter; the
|
||||
/// SFTP usage binds <c>OpenTerminalForFilesHostCommand</c>, which needs none — see the remark on both in
|
||||
/// <c>MainWindowViewModel</c> for why the two directions are not symmetrical.
|
||||
/// </remarks>
|
||||
internal static readonly StyledProperty<ICommand?> OpenCommandProperty =
|
||||
AvaloniaProperty.Register<SessionHeader, ICommand?>(nameof(OpenCommand));
|
||||
|
||||
internal static readonly StyledProperty<object?> OpenCommandParameterProperty =
|
||||
AvaloniaProperty.Register<SessionHeader, object?>(nameof(OpenCommandParameter));
|
||||
|
||||
/// <summary>What the header says instead of an address, while no session/host context is active.</summary>
|
||||
internal static readonly StyledProperty<string?> EmptyTextProperty =
|
||||
AvaloniaProperty.Register<SessionHeader, string?>(nameof(EmptyText));
|
||||
|
||||
public SessionHeader() => InitializeComponent();
|
||||
|
||||
internal string? OpenLabel
|
||||
{
|
||||
get => GetValue(OpenLabelProperty);
|
||||
set => SetValue(OpenLabelProperty, value);
|
||||
}
|
||||
|
||||
internal ICommand? OpenCommand
|
||||
{
|
||||
get => GetValue(OpenCommandProperty);
|
||||
set => SetValue(OpenCommandProperty, value);
|
||||
}
|
||||
|
||||
internal object? OpenCommandParameter
|
||||
{
|
||||
get => GetValue(OpenCommandParameterProperty);
|
||||
set => SetValue(OpenCommandParameterProperty, value);
|
||||
}
|
||||
|
||||
internal string? EmptyText
|
||||
{
|
||||
get => GetValue(EmptyTextProperty);
|
||||
set => SetValue(EmptyTextProperty, value);
|
||||
}
|
||||
}
|
||||
@@ -19,70 +19,113 @@
|
||||
Every row here is a command the shell already exposes for exactly this purpose — see
|
||||
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
|
||||
so this control carries no logic of its own beyond the list it draws and the click it forwards.
|
||||
|
||||
── v5c-4: THE SESSION BLOCK AT THE HEAD, AND THE HEADER ROW THAT IS GONE ─────────────────────────────────
|
||||
The 60-pixel host header that used to sit above the pane on both surfaces has been retired, and its two
|
||||
contents moved up here: the address it printed, and the cross-surface button — "Open SFTP" from a
|
||||
terminal, "Open terminal" from SFTP. Which of the two words it is and which command it runs are resolved
|
||||
by the shell now rather than handed in from the two usage sites; see
|
||||
MainWindowViewModel.SessionCrossSurfaceLabel and OpenOtherSurfaceCommand. The pane keeps that height.
|
||||
|
||||
The address is the fact the header row existed for, so it moves rather than disappears. It sits where the
|
||||
QUICK ACCESS heading used to print the selected tab's short label — that label said less than the address
|
||||
does and would be the same word twice beside it.
|
||||
|
||||
── AND THE COLUMN CLOSES ────────────────────────────────────────────────────────────────────────────────
|
||||
300 pixels is a lot of a 1180-pixel window to give a list that is often two rows long, so the column
|
||||
folds to a 34-pixel rail carrying the way back. A rail rather than nothing: a panel that vanishes without
|
||||
trace is one people report as lost. Both halves live in this control and swap on
|
||||
MainWindowViewModel.IsSessionSidebarOpen, so MainWindow.axaml's own "Auto" column takes whichever width
|
||||
is showing without knowing anything about the state — and the pane beside it grows into what is freed.
|
||||
-->
|
||||
|
||||
<Border Width="300" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="6" Margin="16,20">
|
||||
<Panel>
|
||||
|
||||
<!-- ============ QUICK ACCESS ============ -->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="QUICK ACCESS" FontSize="10" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="10"
|
||||
Foreground="{StaticResource TextGhost}"
|
||||
Text="{Binding SelectedTab.Label}" TextTrimming="CharacterEllipsis" MaxWidth="130" />
|
||||
</Grid>
|
||||
<!-- ============ THE RAIL, WHEN THE COLUMN IS CLOSED ============ -->
|
||||
<!--
|
||||
Painted and bordered like the open column so the closing reads as the same surface narrowing rather
|
||||
than as one piece of furniture being swapped for another.
|
||||
-->
|
||||
<Border Width="34" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
||||
IsVisible="{Binding !IsSessionSidebarOpen}">
|
||||
<Button Classes="flat sidebargrip" VerticalAlignment="Top" Margin="0,20,0,0"
|
||||
Command="{Binding ToggleSessionSidebarCommand}"
|
||||
ToolTip.Tip="Show quick access, snips and the way across to the other surface">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="18"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</Border>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<!--
|
||||
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin
|
||||
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up
|
||||
from the path's string DataContext lands on it.
|
||||
-->
|
||||
<Button Classes="sidebarrow"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
|
||||
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<!-- ============ THE COLUMN ============ -->
|
||||
<Border Width="300" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
||||
IsVisible="{Binding IsSessionSidebarOpen}">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="6" Margin="16,20">
|
||||
|
||||
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
|
||||
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!-- ============ THE SESSION ============ -->
|
||||
<!--
|
||||
The address, and the button that closes the column. Both on one row, and the address is the
|
||||
trimming one: a long account@host:port is exactly the string that would otherwise push the
|
||||
close button off the edge of a panel whose whole point is that it can be got rid of.
|
||||
|
||||
<!-- ============ SNIPS (the terminal surface only) ============ -->
|
||||
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
|
||||
SelectableTextBlock rather than TextBlock, which is what the rest of this application already
|
||||
reaches for whenever a string is on screen to be taken somewhere else — a fingerprint, a public
|
||||
key, a recovery code. This is the one place the shell prints the endpoint it actually dialled,
|
||||
and the endpoint is a thing people paste: into another client, into a ticket, into the ssh
|
||||
command they are writing beside this window. Retyping it off the screen is how a digit gets
|
||||
dropped from an address that is mostly digits.
|
||||
|
||||
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
|
||||
The trimming and the selection do disagree at the long end, and the ToolTip is why the
|
||||
disagreement is tolerable rather than a trap: a drag reaches only the glyphs that were drawn,
|
||||
so an address wearing an ellipsis has to be taken with select-all — Ctrl+A, then Ctrl+C — and
|
||||
the hover already shows what the full string is, so nobody is copying half of one without
|
||||
having been told there is a half.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0,0,0">
|
||||
<SelectableTextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
|
||||
FontSize="12.5" Foreground="{StaticResource AccentText}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}" />
|
||||
<Button Grid.Column="1" Classes="flat sidebargrip"
|
||||
Command="{Binding ToggleSessionSidebarCommand}"
|
||||
ToolTip.Tip="Close this column. The terminal takes the width, and the rail it leaves behind brings it back.">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="18"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible}">
|
||||
<!--
|
||||
The cross-surface button, stretched across the column rather than sized to its own caption: it
|
||||
is the one action in this panel that is not a list row, and a 90-pixel button floating at the
|
||||
left of a 300-pixel column would read as unfinished.
|
||||
-->
|
||||
<Button Classes="headerghost" HorizontalAlignment="Stretch" Margin="0,4,0,10"
|
||||
Content="{Binding SessionCrossSurfaceLabel}"
|
||||
Command="{Binding OpenOtherSurfaceCommand}" />
|
||||
|
||||
<!-- ============ QUICK ACCESS ============ -->
|
||||
<TextBlock Classes="label" Text="QUICK ACCESS" FontSize="10" Margin="8,0" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<!--
|
||||
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin
|
||||
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up
|
||||
from the path's string DataContext lands on it.
|
||||
-->
|
||||
<Button Classes="sidebarrow"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{Binding Snippet.Command}">
|
||||
ToolTip.Tip="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
|
||||
FontSize="11" Foreground="{StaticResource AccentText}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
|
||||
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -90,19 +133,54 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}"
|
||||
ToolTip.Tip="Opens the snippet editor.">
|
||||
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
|
||||
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
<!-- ============ SNIPS (the terminal surface only) ============ -->
|
||||
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<Button Classes="sidebarrow"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{Binding Snippet.Command}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
|
||||
FontSize="11" Foreground="{StaticResource AccentText}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}"
|
||||
ToolTip.Tip="Opens the snippet editor.">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
</UserControl>
|
||||
@@ -42,14 +42,29 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
|
||||
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
||||
Classes.dropped="{Binding IsSessionDropped}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
|
||||
IsVisible="{Binding IsSessionConnected}" />
|
||||
|
||||
<!--
|
||||
Three words where there were two, and the third replaces rather than joins the second — see
|
||||
MainWindowViewModel.IsSessionIdle, which is what keeps them from ever being drawn together.
|
||||
NOT CONNECTED is honest about a tab still dialling and useless about one whose shell has
|
||||
stopped: it says nothing about which of the two endings it was, and the difference between them
|
||||
is the difference between work that is finished and a machine that stopped answering. The strip
|
||||
below carries the sentence and the way back; this is the same fact in the one line that is
|
||||
always on screen.
|
||||
-->
|
||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding !IsSessionConnected}" />
|
||||
IsVisible="{Binding IsSessionIdle}" />
|
||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||
VerticalAlignment="Center" Text="{Binding SessionEndedText}"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||
|
||||
@@ -60,12 +60,31 @@
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
|
||||
<!--
|
||||
Two states, as the strip's own dots always were: green while the shell behind this tab is
|
||||
running, grey while it is connecting and once it has ended. The design's third, amber,
|
||||
state has no meaning here — nothing in this application checks whether a host is merely
|
||||
reachable — so it is not drawn; see design-notes/v5b-fidelity-notes.md.
|
||||
Three states now, where there were two. Green while the shell behind this tab is running
|
||||
and grey once it has ended, as the strip's dots always were — and amber while it is
|
||||
connecting, which used to be grey as well.
|
||||
|
||||
The design's amber had no meaning here while nothing in this application knew how far a
|
||||
connection had got; that changed with the step list, and the note this comment used to
|
||||
carry — that amber is for a host merely reachable, so it is not drawn — is answered
|
||||
rather than ignored. It is not being reachable that is amber, it is being underway. See
|
||||
ConnectingCard.axaml, whose track and running step are the same colour for the same
|
||||
reason, and design-notes/v5b-fidelity-notes.md for the state this is not.
|
||||
|
||||
Worth the third colour because the two it replaces were the same one: a tab still
|
||||
dialling and a tab whose shell has exited both drew grey, which are the two states in
|
||||
this strip with the least in common — one is worth waiting for and the other is over.
|
||||
|
||||
── and a fourth since, splitting that grey ────────────────────────────────────────────
|
||||
A shell that ended and a connection that broke were still one dot, and they are the two
|
||||
dead states with the least in common: one is work finished, the other is a machine that
|
||||
stopped answering and may well still be there. Red for the second, matching the strip
|
||||
under the terminal — see TerminalTabViewModel.IsDropped and SessionEndedBar.axaml, which
|
||||
is where the sentence and the way back live.
|
||||
-->
|
||||
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}"
|
||||
Classes.dropped="{Binding IsDropped}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -9,17 +9,20 @@
|
||||
the same 53px bar: "Back to application" on the left, in place of the wordmark and the search box, and
|
||||
the same three window-control glyphs on the right TitleBar.axaml already draws.
|
||||
|
||||
A separate control rather than a variant of TitleBar itself, on the same reasoning SessionHeader and
|
||||
SessionStatusBar are their own files: nothing here can be measured by a test that hosts the real window,
|
||||
A separate control rather than a variant of TitleBar itself, on the same reasoning SessionStatusBar and
|
||||
SessionSidebar are their own files: nothing here can be measured by a test that hosts the real window,
|
||||
and a control that is either "the wordmark bar" or "the settings bar" depending on a bound flag would be
|
||||
two controls wearing one name. The dragging, maximising and closing logic is duplicated from TitleBar's
|
||||
own code-behind rather than shared through a base class — four short handlers, and the day one of the two
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,16 @@
|
||||
-->
|
||||
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||
<!-- CTRL K, not the design's ⌘K — see the remark at the top of this file. -->
|
||||
<Border Grid.Column="2" Width="34" Height="18" CornerRadius="5"
|
||||
<!--
|
||||
CTRL K, not the design's ⌘K — see the remark at the top of this file.
|
||||
|
||||
◆ PADDED RATHER THAN 34 WIDE, which is the design's own width for a chip reading ⌘K: two
|
||||
glyphs, where the substitution this bar makes is six characters and a space. At 10.5 mono
|
||||
that run is wider than 34, so the chip clipped it — "CTRL" with the K cut in half. MinWidth
|
||||
keeps the design's footprint for the day this face has a ⌘ to draw, and the padding is what
|
||||
the longer label actually needs.
|
||||
-->
|
||||
<Border Grid.Column="2" MinWidth="34" Height="18" CornerRadius="5" Padding="7,0"
|
||||
Background="{StaticResource KbdChip}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
|
||||
|
||||
@@ -33,6 +33,43 @@
|
||||
layer has not got. See docs/design-import-gaps.md.
|
||||
-->
|
||||
|
||||
<!--
|
||||
══ THE TWO LISTINGS' COLUMNS ═════════════════════════════════════════════════════════════════════════
|
||||
◆ They were fixed pixel widths, and fixed was wrong at both ends of the range this screen actually gets.
|
||||
|
||||
The numbers — 20/*/60/56 here and 20/*/56/52/48 across the arrow column — were chosen against the 204
|
||||
pixels the session shell's own QUICK ACCESS sidebar leaves a pane at the window's minimum. Measured,
|
||||
they were wrong in both directions at once:
|
||||
|
||||
· MODIFIED prints "2026-08-21 11:36", which needs 116 pixels, and never had more than 48. So the
|
||||
column was truncated at every width this screen has ever been drawn at — a maximised window showed
|
||||
"2026-08" beside a NAME column with four hundred empty pixels in it. PERMS needs 60 for a
|
||||
ten-character mode and had 48; SIZE needs 58 for "999.9 GB" and had 56.
|
||||
· At 204 the remote pane's NAME column measured **zero pixels wide**. Twenty for the glyph plus
|
||||
56+52+48 for the numbers is 176 of the 180 a row has, so the file names — the one column a file
|
||||
browser exists to show — were not drawn at all.
|
||||
|
||||
Star columns with a MaxWidth fix both, because the cap is what makes "share the width" mean the right
|
||||
thing at each end. Wide, the three numeric columns stop growing at what their own text needs and NAME
|
||||
keeps every remaining pixel. Narrow, nothing has a MinWidth to overflow with, so all four give ground
|
||||
together and NAME — weighted heaviest — keeps enough to read a name by.
|
||||
|
||||
MODIFIED carries the extra weight (1.6) so that it reaches its cap first: it is the widest content and
|
||||
the column somebody is most often actually reading. The local pane's NAME is 3* against the remote's 2*,
|
||||
which is the remote's PERMS weight folded into it — that is what makes SIZE and MODIFIED come out the
|
||||
same width on both sides of the arrow column, so the same amount of a timestamp is visible in each. See
|
||||
Timestamps.Format for why both panes write one the same way in the first place; a shared format read at
|
||||
two different widths would have made that argument for nothing.
|
||||
|
||||
◆ FOUR COPIES OF THIS, and they have to stay in step: a heading row and a row template per pane. Avalonia
|
||||
cannot share one ColumnDefinitions instance between two Grids — each mutates its own computed sizes —
|
||||
and SharedSizeGroup only applies to Auto columns. What catches a copy that drifts is measurement rather
|
||||
than discipline: ScreenLayoutTests holds every cell against the width its own text needs
|
||||
(TheListingColumnsShowTheirContentInFullWhenThereIsRoom), the headings against each other
|
||||
(TheListingHeadingsDoNotRunIntoEachOther), and NAME against zero at the narrowest budget
|
||||
(TheNameColumnSurvivesTheNarrowestBudget).
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
<!--
|
||||
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -259,10 +296,18 @@
|
||||
the row template below for why the whole column set was chosen against 204 pixels rather than the
|
||||
design's own 1920-pixel canvas.
|
||||
-->
|
||||
<Grid Grid.Row="2" ColumnDefinitions="20,*,60,56" Margin="0,2,10,4">
|
||||
<Grid Grid.Row="2" Margin="0,2,10,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="20" />
|
||||
<ColumnDefinition Width="3*" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
|
||||
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" Margin="8,0,0,0"
|
||||
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
@@ -274,14 +319,25 @@
|
||||
SelectedItem="{Binding SelectedLocalEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="20,*,60,56" Height="38" Margin="14,0,10,0">
|
||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
<Grid Height="38" Margin="6,0,10,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="24" />
|
||||
<ColumnDefinition Width="20" />
|
||||
<ColumnDefinition Width="3*" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- The remote pane's own tick, on this side and for the same reasons. -->
|
||||
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
|
||||
IsChecked="{Binding IsChecked}"
|
||||
Click="OnLocalRowChecked" />
|
||||
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Text="{Binding Name}" FontSize="13.5"
|
||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
@@ -327,10 +383,12 @@
|
||||
|
||||
<!-- ==== The host ==== -->
|
||||
<!--
|
||||
Six rows rather than five: the connection's own strip is the new one, and it is inside this pane
|
||||
rather than above both because it is about this pane and nothing else.
|
||||
Seven rows. Two of them are furniture over a listing rather than part of it: the connection's own
|
||||
strip, and the row of things that can be done to whichever row is chosen. Both are inside this pane
|
||||
rather than above both panes because both are about this one and nothing else. The two questions —
|
||||
deleting, and renaming or moving — share a single row, since only one of the two is ever on screen.
|
||||
-->
|
||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
|
||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*"
|
||||
DragDrop.AllowDrop="True">
|
||||
|
||||
<!--
|
||||
@@ -344,25 +402,48 @@
|
||||
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
||||
-->
|
||||
<!--
|
||||
The path is alone in the star column for the reason the local pane's header remark spells out:
|
||||
TextTrimming needs the finite width only a star column gives it, and this is the pane where the
|
||||
long path is not even unusual — it is any host with a deep directory tree.
|
||||
◆ THE PATH IS TYPED HERE, and that is what the star column is now holding.
|
||||
|
||||
It was a TextBlock printing where the pane was. A breadcrumb only reaches what is under the
|
||||
directory already listed, so crossing a host's filesystem was a walk to the common ancestor and
|
||||
back down — a click and a round trip a level — and a path somebody already knew could not be
|
||||
entered at all. Enter goes; Escape puts back what the pane is really showing. See
|
||||
TransfersViewModel.RemotePathDraft for why the box holds its own value rather than RemotePath
|
||||
itself, and SftpPath.Resolve for what an entry that is not absolute means.
|
||||
|
||||
Still alone in the star column, for the reason the local pane's header remark spells out: this box
|
||||
has to be bounded, and an Auto column beside a StackPanel would measure it at its content's width
|
||||
and push the buttons past the pane's edge.
|
||||
|
||||
DELETE has left this row. It acts on the selected row rather than on the pane, and it is one of
|
||||
three that do now — see the item row below, which is where the three of them are together and
|
||||
where there is room for three rather than for one.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"
|
||||
Margin="0,0,12,0">
|
||||
Margin="0,0,8,0">
|
||||
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsHostPicker}" />
|
||||
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsBucketPicker}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left"
|
||||
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding RemotePath}" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
|
||||
<TextBox Grid.Column="1" Classes="pathbox" VerticalAlignment="Center"
|
||||
Text="{Binding RemotePathDraft}" IsEnabled="{Binding IsConnected}"
|
||||
PlaceholderText="path"
|
||||
ToolTip.Tip="Type a path and press Enter. A relative path is taken from here; ~ is the account's home directory.">
|
||||
<!--
|
||||
One word, because the only time this placeholder shows is on a pane with nothing connected,
|
||||
where the box is disabled and the invitation below is doing the explaining. "/path/to/
|
||||
somewhere" needs 146 pixels and had 91 at the session shell's narrowest, so it drew as a
|
||||
path cut off mid-segment. What the box is for is in the tooltip, where there is room for it.
|
||||
-->
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding GoToTypedRemotePathCommand}" />
|
||||
<KeyBinding Gesture="Escape" Command="{Binding RevertTypedRemotePathCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
@@ -371,10 +452,6 @@
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
|
||||
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -382,8 +459,9 @@
|
||||
<!--
|
||||
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
|
||||
|
||||
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: SessionHeader now
|
||||
prints the very same address above this whole screen — see MainWindowViewModel.SessionAddress,
|
||||
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: the session shell
|
||||
prints the very same address beside this screen — in the sidebar's own session block since v5c-4
|
||||
retired the header row that printed it above — see MainWindowViewModel.SessionAddress,
|
||||
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
|
||||
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
|
||||
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
|
||||
@@ -401,9 +479,167 @@
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The question DELETE asks. Under the button rather than over the pane, so the row it is about is
|
||||
still on screen and still selected while it is being answered — and it names the full path rather
|
||||
than the file, because a name is the half that does not identify anything.
|
||||
◆ WHAT CAN BE DONE TO THE ROW THAT IS CHOSEN. Only while something is connected.
|
||||
|
||||
Three buttons in one place, which is the point. DELETE used to sit alone in the pane header beside
|
||||
UP and REFRESH — two things that move the pane and one that destroys a file, in one row — and
|
||||
there was no rename and no move at all, so a file that had landed under the wrong name could only
|
||||
be put right by deleting it and sending it again. These three act on the selection, so they belong
|
||||
together and away from the two that act on the pane.
|
||||
|
||||
The name beside them is the row they are about, ellipsized. It says which row rather than which
|
||||
path: the listing directly underneath has that row highlighted, so a full path here would be
|
||||
repeating what the selection is already showing. The forms the buttons open do print the path,
|
||||
because that is the moment something is committed rather than merely looked at.
|
||||
|
||||
The row goes while one of the three questions is up rather than sitting there greyed out, and so
|
||||
does the breadcrumb below it — see ShowsRemoteItemActions. The form that opens is the answer to
|
||||
what these buttons asked, and inside the session shell this pane is 204 pixels wide: the height
|
||||
goes to the question or to the buttons that raised it, not to both.
|
||||
-->
|
||||
<Border Grid.Row="2" Padding="12,5" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1" IsVisible="{Binding ShowsRemoteItemActions}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<!--
|
||||
The chosen row's name, or the count once anything is ticked. The count wins because it is the
|
||||
one the buttons beside it are actually about: with three rows gathered up, a name would be
|
||||
describing whichever of them happens to be highlighted while DELETE takes all three.
|
||||
-->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button Classes="ghost" Content="ALL" Command="{Binding ToggleAllRemoteCommand}"
|
||||
ToolTip.Tip="Tick every row, or untick them all" />
|
||||
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding SelectedRemoteName}"
|
||||
IsVisible="{Binding !HasCheckedRemote}" />
|
||||
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource AccentText}"
|
||||
Text="{Binding CheckedRemoteLabel}"
|
||||
IsVisible="{Binding HasCheckedRemote}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||
<!--
|
||||
◆ FOUR NOW, and this is the one that was missing: the PERMS column has been a true fact
|
||||
about a file with nothing to be done about it since it arrived, so the commonest next act
|
||||
after using this screen was opening a terminal to chmod what had just been uploaded. Dark on
|
||||
a bucket rather than live-and-refused — an object has an access policy rather than nine bits;
|
||||
see CanChangeRemotePermissions.
|
||||
|
||||
A lock rather than a key glyph: a key is what this application's keychain uses, and one here
|
||||
would read as "which credential" over a row that has nothing to do with one.
|
||||
-->
|
||||
<!--
|
||||
◆ EDIT, and it is first because it is the only one of the five that changes nothing on the
|
||||
host by pressing it: it fetches a copy and opens it. Dark on a head with no way to open a
|
||||
file or watch one — see CanEditRemote, which is what the phone fails rather than this
|
||||
button being absent there for a reason nobody can see.
|
||||
-->
|
||||
<Button Classes="paneicon" Command="{Binding EditRemoteCommand}"
|
||||
IsEnabled="{Binding CanEditRemote}"
|
||||
ToolTip.Tip="Open in this machine's editor, and send each save back">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding ChangeRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding CanChangeRemotePermissions}"
|
||||
ToolTip.Tip="Permissions on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
|
||||
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding MoveRemoteCommand}"
|
||||
IsEnabled="{Binding CanMoveRemote}" ToolTip.Tip="Move to another directory">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
|
||||
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ WHAT AN EDIT SESSION SAYS FOR ITSELF, and it is a strip rather than a question: nothing is
|
||||
waiting on an answer, a file is simply open somewhere else and each save is going back. It shares
|
||||
the questions' Grid row because it is exclusive with them — opening any of the three needs a
|
||||
selection, and while one is up ShowsRemoteItemActions has taken the buttons away.
|
||||
|
||||
The log is newest-first and bounded. Watching means saving happens more than once, and each one is
|
||||
a round trip somebody may want to see happened; an editor that autosaves would otherwise grow this
|
||||
without limit. See TransfersViewModel.RemoteEditLog.
|
||||
|
||||
The conflict card inside it is the one state where a save is not written back. Three answers,
|
||||
because those are the three that exist: send it anyway, throw away what was typed and take the
|
||||
host's copy, or stop. There is deliberately no merge — this client has no way to do one, and a
|
||||
button that pretended otherwise would be the fabrication this project's honesty rule forbids.
|
||||
-->
|
||||
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding IsEditingRemote}">
|
||||
<!--
|
||||
◆ THE WHOLE STRIP SCROLLS, not just the log inside it, and that is what makes this safe at any
|
||||
width. It is the tallest thing that can be in this Grid row — a heading, a path, a log, a
|
||||
conflict card and a button — and unlike the three questions beside it, it is not modal: the
|
||||
listing underneath stays live, because a file being open in an editor is no reason to stop
|
||||
being able to browse. So it cannot be allowed to take the pane's height, and bounding the
|
||||
container rather than each part means nothing added inside it later can undo that.
|
||||
|
||||
134 is measured. Inside the session shell this pane is 204x377, and ScreenLayoutTests draws
|
||||
this in its worst case — the conflict card open with a session's worth of saves behind it.
|
||||
-->
|
||||
<ScrollViewer MaxHeight="134" VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
||||
Text="{Binding RemoteEdit.Question}" />
|
||||
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding RemoteEdit.RemotePath}" />
|
||||
|
||||
<!-- Bounded inside a bounded strip: this keeps a long session's log from pushing the conflict
|
||||
card and STOP EDITING out of view within the scroller. -->
|
||||
<ScrollViewer MaxHeight="48" VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding RemoteEditLog}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Classes="mono" FontSize="11" Margin="0,1"
|
||||
Foreground="{StaticResource TextGhost}" TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<Border Padding="10,8" CornerRadius="8" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1"
|
||||
IsVisible="{Binding HasRemoteEditConflict}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding RemoteEditConflict}" />
|
||||
<WrapPanel>
|
||||
<Button Classes="ghost" Content="UPLOAD ANYWAY" Margin="0,0,8,4"
|
||||
Command="{Binding ForceSaveEditCommand}" />
|
||||
<Button Classes="ghost" Content="RELOAD" Margin="0,0,0,4"
|
||||
Command="{Binding ReloadEditCommand}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Button Classes="ghost" Content="STOP EDITING" HorizontalAlignment="Left"
|
||||
Command="{Binding StopEditingCommand}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The question DELETE asks. It names the full path rather than the file, because a name is the half
|
||||
that does not identify anything — and that is now the whole of how the question says which row it
|
||||
is about: at the session shell's own 204-pixel width there is no height left for a listing under
|
||||
it, so "the row is still on screen and still selected" is no longer true and the path in the card
|
||||
is what carries the identity. See the rename form below, which is measured in the same shape.
|
||||
|
||||
This is the strongest warning on any of these screens, and deliberately: everything else this
|
||||
application deletes is a tombstone against a copy the server still has, and a file on somebody's
|
||||
@@ -414,15 +650,34 @@
|
||||
CANCEL side by side want closer to 220, so at the narrow width they wrap onto their own line
|
||||
instead of one of them going off the edge, which the WrapPanel achieves for free.
|
||||
-->
|
||||
<Border Grid.Row="2" Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding IsConfirmingRemoteDeletion}">
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteDeletion.Question}" />
|
||||
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource Danger}"
|
||||
Text="{Binding PendingRemoteDeletion.FullPath}" />
|
||||
<!--
|
||||
Every path, one per line, and never a count on its own: agreeing to "3 items" is agreeing to a
|
||||
number. See RemoteDeletionRequest.Listing.
|
||||
|
||||
◆ 32, AND THE NUMBER IS MEASURED RATHER THAN CHOSEN. A ticked set has no ceiling, so this list
|
||||
has to scroll, and the budget it scrolls inside is small: within the session shell this pane is
|
||||
204 wide, where one remote path wraps to two lines, and the card also carries a heading, the
|
||||
consequence and two buttons that wrap onto two rows at that width. 120 put the buttons outside
|
||||
the window; 72 left CANCEL three pixels under it; 56 and 40 pushed the listing's own squeezed
|
||||
row past the bottom — each caught by ScreenLayoutTests, which is why the five-path test exists
|
||||
beside the single-path one rather than instead of it. 32 is one wrapped path, which is exactly
|
||||
what the single-path question has always shown.
|
||||
|
||||
What that buys is a scrollbar rather than a taller card, and the heading carries the count so
|
||||
the number is never the thing being scrolled for.
|
||||
-->
|
||||
<ScrollViewer MaxHeight="32" VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource Danger}"
|
||||
Text="{Binding PendingRemoteDeletion.Listing}" />
|
||||
</ScrollViewer>
|
||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||
<WrapPanel>
|
||||
@@ -434,7 +689,149 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
||||
<!--
|
||||
◆ RENAME AND MOVE, in the row the deletion question uses and never at the same time as it.
|
||||
|
||||
One form for both, because they differ only in what the box starts out holding and in the words
|
||||
around it — see RemoteMoveRequest, where both sets are chosen. It shares this Grid row with
|
||||
the deletion card above: the three questions are mutually exclusive by construction, each one's
|
||||
own opening clearing the others, so a row of its own would be a row that is empty whenever this
|
||||
one is not.
|
||||
|
||||
It is not painted in the deletion card's danger colours, and that is deliberate rather than an
|
||||
oversight. A rename is undone by renaming back; the deletion beside it is the one thing this
|
||||
application does that nothing can walk back, and spending the same red on both would make the red
|
||||
stop meaning anything.
|
||||
|
||||
Enter commits from inside the box, which is where the hands already are once an answer has been
|
||||
typed.
|
||||
|
||||
◆ IT TAKES THE WHOLE PANE at the session shell's own 204-pixel width, and that is measured rather
|
||||
than hoped: the listing's star row is squeezed to nothing while either question is open, exactly
|
||||
as the deletion card above already squeezed it. There is no room for a form and a listing in 336
|
||||
pixels, and the form carries the full path precisely so the row underneath does not have to be
|
||||
visible for the question to be about something identifiable. That is why the item row and the
|
||||
breadcrumb step aside instead — the height they free is what keeps the form itself un-clipped,
|
||||
which is what ScreenLayoutTests measures.
|
||||
-->
|
||||
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding IsMovingRemote}">
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteMove.Question}" />
|
||||
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding PendingRemoteMove.FullPath}" />
|
||||
<TextBox Text="{Binding RemoteMoveTarget}"
|
||||
PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConfirmMoveRemoteCommand}" />
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CancelMoveRemoteCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteMove.Note}" />
|
||||
<WrapPanel>
|
||||
<Button Classes="accent" Content="{Binding PendingRemoteMove.ConfirmLabel}" Margin="0,0,8,4"
|
||||
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
|
||||
Command="{Binding CancelMoveRemoteCommand}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ THE MODE, as nine boxes and the number they add up to.
|
||||
|
||||
In the same Grid row as the other two questions and never at the same time as either — each one's
|
||||
own opening clears the others, which is what makes one row enough for all three.
|
||||
|
||||
Boxes rather than a field to type 755 into, and that is the design rather than a preference: the
|
||||
mode is already drawn as -rwxr-xr-x in the column this form was opened from, so a who-can-do-what
|
||||
grid is the same representation the reader is already holding. An octal field would ask them to
|
||||
translate something they can see into something they cannot check. The octal is printed underneath
|
||||
as it changes, so the two readings check each other rather than one replacing the other.
|
||||
|
||||
The EXEC column is headed from the request rather than fixed, because on a directory that bit is
|
||||
permission to enter it — the commonest misreading of a POSIX mode there is, and one word fixes it.
|
||||
See RemotePermissionsRequest.ExecuteLabel.
|
||||
|
||||
APPLY is dead until something has actually changed. A round trip that sets a mode to the mode it
|
||||
already has is not wrong, only pointless, and a live button over an untouched form invites it.
|
||||
|
||||
Not painted in the deletion card's red. Changing a mode is undone by changing it back; the red is
|
||||
spent on the one thing here that nothing walks back, and spending it twice would make it stop
|
||||
meaning anything — the same argument the rename form above makes for itself.
|
||||
-->
|
||||
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding IsChangingRemotePermissions}">
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemotePermissions.Question}" />
|
||||
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding PendingRemotePermissions.FullPath}" />
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,Auto,Auto,Auto" Margin="0,2">
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Classes="hint" FontSize="10.5" Text="read"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" Classes="hint" FontSize="10.5" Text="write"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="3" Classes="hint" FontSize="10.5"
|
||||
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
|
||||
HorizontalAlignment="Center" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="11.5" Text="owner"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="11.5" Text="group"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
|
||||
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
|
||||
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="11.5" Text="others"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
|
||||
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
|
||||
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
|
||||
</Grid>
|
||||
|
||||
<SelectableTextBlock Classes="mono" FontSize="12.5"
|
||||
Foreground="{StaticResource AccentText}"
|
||||
Text="{Binding PendingRemotePermissions.Preview}" />
|
||||
|
||||
<WrapPanel>
|
||||
<Button Classes="accent" Content="APPLY" Margin="0,0,8,4"
|
||||
Command="{Binding ConfirmRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
|
||||
Command="{Binding CancelRemotePermissionsCommand}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Away while a question is up, for the reason the item row above states: the trail is where the pane
|
||||
is and MKDIR makes a directory, and neither is what somebody answering a question about one row
|
||||
is doing.
|
||||
-->
|
||||
<Grid Grid.Row="4" ColumnDefinitions="*,Auto" Margin="12,6,12,4"
|
||||
IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -466,10 +863,16 @@
|
||||
is showing, 140 plus MKDIR plus the spacing between them ran past the pane's own edge. Still wide
|
||||
enough for a real directory name to be typed and read back, which is what this box is for; a
|
||||
name that outgrows it scrolls inside the box the way every TextBox already does.
|
||||
|
||||
◆ The placeholder says "directory" rather than "new directory", which is what it said until it
|
||||
was measured: the longer text needs 101 pixels inside a box that has 74, so it drew as "new
|
||||
directo" — a placeholder cut off mid-word, which reads as a rendering fault rather than as a
|
||||
hint. MKDIR is right beside it and carries the "new" half already. The path box in the header
|
||||
above lost the same argument and for the same reason; see its own PlaceholderText.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
||||
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="directory" />
|
||||
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
@@ -479,28 +882,63 @@
|
||||
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
|
||||
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
|
||||
-->
|
||||
<Grid Grid.Row="4" ColumnDefinitions="20,*,56,52,48" Margin="0,2,10,4">
|
||||
<Grid Grid.Row="5" Margin="0,2,10,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="20" />
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
|
||||
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" Margin="8,0,0,0"
|
||||
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" Margin="6,0,0,0"
|
||||
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="5" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
||||
<ListBox Grid.Row="6" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="20,*,56,52,48" Height="38" Margin="14,0,10,0">
|
||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
<Grid Height="38" Margin="6,0,10,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="24" />
|
||||
<ColumnDefinition Width="20" />
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<!--
|
||||
◆ THE TICK IS NOT THE LIST'S SELECTION, and the two answer different questions. The
|
||||
selection is "the row I am looking at", which is what rename, move and permissions act on
|
||||
— each takes a form naming one path and is one-at-a-time by construction. This is "the
|
||||
rows I have gathered up", which is what DOWNLOAD and DELETE act on, and it survives the
|
||||
highlight moving.
|
||||
|
||||
It is drawn rather than left to ctrl-click for a reason beyond taste: the phone head shares
|
||||
this view model and has neither ctrl nor shift, so a multi-selection has to be a tap target
|
||||
there whatever this pane does. One shape on both heads, and a state somebody can see.
|
||||
|
||||
Click is handled rather than bound one-way, because the count above the list is a property
|
||||
of the collection rather than of any row — see TransfersViewModel.RemoteChecksChanged.
|
||||
-->
|
||||
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
|
||||
IsChecked="{Binding IsChecked}"
|
||||
Click="OnRemoteRowChecked" />
|
||||
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Classes.exec="{Binding IsExecutable}"
|
||||
Text="{Binding Name}" FontSize="13.5"
|
||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||||
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="5" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||||
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
@@ -512,7 +950,7 @@
|
||||
A directory with nothing in it. It no longer has to say anything about not being connected: that
|
||||
state is the invitation below, which covers this pane whole.
|
||||
-->
|
||||
<TextBlock Grid.Row="5" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
|
||||
<TextBlock Grid.Row="6" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
|
||||
Text="Nothing in this directory." IsVisible="{Binding !HasRemoteEntries}" />
|
||||
|
||||
@@ -534,8 +972,14 @@
|
||||
the picker arrives, so the tall shape is the form rather than form-plus-prose, and neither shape
|
||||
reaches the floor. Anything added here has to hold that; the layout suite measures both.
|
||||
-->
|
||||
<Border Grid.Row="1" Grid.RowSpan="5" Background="{StaticResource Canvas}"
|
||||
IsVisible="{Binding !IsConnected}">
|
||||
<!--
|
||||
ShowsRemoteInvitation rather than !IsConnected, which are no longer the same question. Pressing
|
||||
DISCONNECT empties this pane, so an invitation over it covers nothing; a connection lost
|
||||
underneath somebody leaves the listing exactly where it was, and covering that would hide the one
|
||||
thing the strip below is offering to put them back into. See TransfersViewModel.ShowsRemoteContents.
|
||||
-->
|
||||
<Border Grid.Row="1" Grid.RowSpan="6" Background="{StaticResource Canvas}"
|
||||
IsVisible="{Binding ShowsRemoteInvitation}">
|
||||
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
|
||||
@@ -678,11 +1122,11 @@
|
||||
window that has stopped answering, and the answer arriving after the drop is the answer arriving
|
||||
too late. See the local pane for why neither may hit-test.
|
||||
-->
|
||||
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
|
||||
<Border Grid.Row="0" Grid.RowSpan="7" IsHitTestVisible="False"
|
||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
|
||||
|
||||
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
|
||||
<Border Grid.Row="0" Grid.RowSpan="7" IsHitTestVisible="False"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
|
||||
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="12"
|
||||
|
||||
@@ -79,6 +79,20 @@ internal sealed partial class TransfersScreen : UserControl
|
||||
|
||||
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
|
||||
|
||||
/// <summary>
|
||||
/// Tells the screen a row's tick moved, so the count and the buttons that read it keep up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The tick itself is two-way bound and needs nothing from here; what does is the count above the list,
|
||||
/// which is a property of the collection rather than of any row. Subscribing per row would be a
|
||||
/// subscription to attach and detach on every navigation, since each listing replaces every row it has.
|
||||
/// See <c>TransfersViewModel.RemoteChecksChanged</c>.
|
||||
/// </remarks>
|
||||
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) => Transfers?.RemoteChecksChanged();
|
||||
|
||||
/// <inheritdoc cref="OnRemoteRowChecked" />
|
||||
private void OnLocalRowChecked(object? sender, RoutedEventArgs e) => Transfers?.LocalChecksChanged();
|
||||
|
||||
private void OnLocalActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (Transfers is { } transfers)
|
||||
|
||||
@@ -72,6 +72,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"Microsoft.TSS": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.1.1, )",
|
||||
"resolved": "2.1.1",
|
||||
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
@@ -405,7 +411,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -465,9 +471,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -567,11 +573,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -55,6 +55,24 @@ internal sealed class S3FileStore : IRemoteFileStore
|
||||
/// </remarks>
|
||||
public bool IsConnected => Volatile.Read(ref disposed) == 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Null forever, for the reason <see cref="IsConnected"/> is always true: there is no connection under
|
||||
/// this to be lost. A request that fails reports itself, and nothing here outlives one.
|
||||
/// </remarks>
|
||||
public string? TransportFailure => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Never raised; see <see cref="TransportFailure"/>. Declared rather than left off the type because the
|
||||
/// screen holds whichever store it opened as an <c>IRemoteFileStore</c> and subscribes without asking
|
||||
/// which kind it got — an event that is simply never raised is what makes that honest rather than a
|
||||
/// cast and a branch.
|
||||
/// </remarks>
|
||||
#pragma warning disable CS0067 // The event is never used
|
||||
public event EventHandler? ConnectionLost;
|
||||
#pragma warning restore CS0067
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory => ObjectKeys.Root;
|
||||
|
||||
@@ -383,6 +401,32 @@ internal sealed class S3FileStore : IRemoteFileStore
|
||||
/// a rename's clothing, and the failure mode is a half-moved directory.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Refused rather than approximated. An object is governed by an ACL and a bucket policy, which are not
|
||||
/// nine bits and do not map onto them — writing something plausible here would put a mode in a listing
|
||||
/// that no request would ever be decided by.
|
||||
/// </remarks>
|
||||
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken) =>
|
||||
throw new SftpPathException(
|
||||
path,
|
||||
"An object in a bucket has an access policy rather than a POSIX mode, so there is nothing here to set.");
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Refused for the reason above: an object's timestamp is when the service stored it, and there is no
|
||||
/// request that says otherwise. A copy that claimed the source's time would be a date the bucket does
|
||||
/// not agree with.
|
||||
/// </remarks>
|
||||
public Task SetLastWriteTimeAsync(
|
||||
string path,
|
||||
DateTimeOffset lastWriteTimeUtc,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new SftpPathException(
|
||||
path,
|
||||
"An object's timestamp is when the bucket stored it, and cannot be set to anything else.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fromPath);
|
||||
|
||||
@@ -55,14 +55,14 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -81,11 +81,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,9 +23,13 @@ namespace DodoSSH.Client.Session;
|
||||
/// <param name="AutomaticUpdateChecks">
|
||||
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
|
||||
/// </param>
|
||||
/// <param name="SessionSidebarOpen">
|
||||
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
|
||||
/// </param>
|
||||
public sealed record ClientSettings(
|
||||
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
|
||||
bool AutomaticUpdateChecks = true)
|
||||
bool AutomaticUpdateChecks = true,
|
||||
bool SessionSidebarOpen = true)
|
||||
{
|
||||
/*
|
||||
A positional record, and the defaults live on the parameters rather than on property initializers.
|
||||
@@ -102,6 +106,24 @@ public sealed record ClientSettings(
|
||||
warns against.
|
||||
*/
|
||||
|
||||
/*
|
||||
SessionSidebarOpen: why closing the sidebar is remembered, and why it is remembered here.
|
||||
|
||||
On by default, because the sidebar is where a session's pins, its snips and the way across to the
|
||||
other surface live — a first launch that hid all three would be hiding the feature rather than
|
||||
offering to.
|
||||
|
||||
Remembered at all because closing it is a choice about how much of a 1180-pixel window a terminal
|
||||
gets, and a choice that has to be made again on every launch is one the application is not really
|
||||
offering. It belongs in this file rather than in the vault for the same reason the font size does:
|
||||
it is a fact about this screen, not about this keychain, and following somebody from a 27-inch
|
||||
monitor onto a laptop would be a preference nobody asked for.
|
||||
|
||||
Not per-surface and not per-tab. The sidebar is one control drawn on two screens — see
|
||||
SessionSidebar.axaml — and a window where it is open on SFTP and closed on the terminal is a
|
||||
window that appears to lose it at random.
|
||||
*/
|
||||
|
||||
/// <summary>Brings a value inside the range this type will store.</summary>
|
||||
public static int ClampTerminalFontSize(int pixels) =>
|
||||
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -187,9 +187,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -289,11 +289,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,129 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// One named part of making a connection, in the order they happen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="SshConnectionPhase"/> with one more at the front. The SSH assembly reports four phases and
|
||||
/// knows about no others, which is correct for it — it has never heard of a renderer. But the first thing a
|
||||
/// connection here waits on is the terminal page attaching its socket, and on the first connection after a
|
||||
/// cold start that is a real wait with a real failure mode of its own: a missing WebView2 runtime. A step
|
||||
/// list that began at "reaching the host" would leave the one wait most likely to hang unnamed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Declared here rather than shared with the SSH layer for that reason, and the mapping between the two is
|
||||
/// one <c>switch</c> in <c>VaultViewModel</c>. The numbering is the order and the order is load-bearing:
|
||||
/// <see cref="TerminalTabViewModel.Advance"/> compares these values to decide what is already behind it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal enum ConnectionStep
|
||||
{
|
||||
/// <summary>Waiting for the renderer to attach, before anything is dialled.</summary>
|
||||
PreparingTerminal = 0,
|
||||
|
||||
/// <inheritdoc cref="SshConnectionPhase.Reaching" />
|
||||
Reaching = 1,
|
||||
|
||||
/// <inheritdoc cref="SshConnectionPhase.CheckingHostKey" />
|
||||
CheckingHostKey = 2,
|
||||
|
||||
/// <inheritdoc cref="SshConnectionPhase.Authenticating" />
|
||||
Authenticating = 3,
|
||||
|
||||
/// <inheritdoc cref="SshConnectionPhase.OpeningShell" />
|
||||
OpeningShell = 4,
|
||||
}
|
||||
|
||||
/// <summary>How one step of a connection is getting on.</summary>
|
||||
/// <remarks>
|
||||
/// Four states rather than a bool per row, because a step list is read as a sequence and the reader's
|
||||
/// question at each row is which of the four this is: behind us, happening, not yet, or where it stopped.
|
||||
/// <see cref="Stopped"/> exists only for the row a failure landed on — see
|
||||
/// <see cref="TerminalTabViewModel.Failed"/> — and is what turns the list from a progress bar into an
|
||||
/// account of how far the attempt got.
|
||||
/// </remarks>
|
||||
internal enum ConnectionStepState
|
||||
{
|
||||
/// <summary>Not started. Nothing is known about it yet.</summary>
|
||||
Pending = 0,
|
||||
|
||||
/// <summary>Happening now.</summary>
|
||||
Running = 1,
|
||||
|
||||
/// <summary>Finished, because something after it started.</summary>
|
||||
Done = 2,
|
||||
|
||||
/// <summary>Where the attempt stopped. There is no step after this one.</summary>
|
||||
Stopped = 3,
|
||||
}
|
||||
|
||||
/// <summary>One row of the connecting card's step list.</summary>
|
||||
/// <remarks>
|
||||
/// A view model per step rather than an index the view compares against, because each row draws its own
|
||||
/// state and an <c>ItemsControl</c> has no way to ask "am I before the current one?" — the alternative was a
|
||||
/// converter taking two bindings, which is the same comparison written somewhere it cannot be tested.
|
||||
/// </remarks>
|
||||
internal sealed partial class ConnectionStepViewModel : ObservableObject
|
||||
{
|
||||
internal ConnectionStepViewModel(ConnectionStep step, string caption)
|
||||
{
|
||||
Step = step;
|
||||
Caption = caption;
|
||||
}
|
||||
|
||||
/// <summary>Which step this is.</summary>
|
||||
internal ConnectionStep Step { get; }
|
||||
|
||||
/// <summary>What the row says, in the present tense of the thing being waited on.</summary>
|
||||
internal string Caption { get; }
|
||||
|
||||
/// <inheritdoc cref="ConnectionStepState" />
|
||||
[ObservableProperty]
|
||||
private ConnectionStepState state;
|
||||
|
||||
/// <summary>Whether this step is the one happening now.</summary>
|
||||
internal bool IsRunning => State is ConnectionStepState.Running;
|
||||
|
||||
/// <summary>Whether this step finished.</summary>
|
||||
internal bool IsDone => State is ConnectionStepState.Done;
|
||||
|
||||
/// <summary>Whether the attempt stopped on this step.</summary>
|
||||
internal bool IsStopped => State is ConnectionStepState.Stopped;
|
||||
|
||||
/// <summary>
|
||||
/// The character drawn beside the caption for whichever state this is in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Here rather than in a converter for the reason <c>TransferRowViewModel.StatusWord</c> is: the mapping
|
||||
/// is four cases with no arithmetic, and a converter would put it in a file the shell's tests cannot
|
||||
/// reach. The colours stay in the view, where the palette is.
|
||||
/// <para>
|
||||
/// Four distinguishable shapes rather than one recoloured, because the difference between a step that
|
||||
/// finished and a step still running has to survive somebody who cannot tell this design's green from
|
||||
/// its amber.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string Mark => State switch
|
||||
{
|
||||
ConnectionStepState.Done => "✓",
|
||||
ConnectionStepState.Running => "●",
|
||||
ConnectionStepState.Stopped => "✕",
|
||||
_ => "○",
|
||||
};
|
||||
|
||||
partial void OnStateChanged(ConnectionStepState value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsStopped));
|
||||
OnPropertyChanged(nameof(Mark));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How far along a tab's connection is.
|
||||
/// </summary>
|
||||
@@ -57,8 +179,23 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
{
|
||||
Label = label;
|
||||
Address = address;
|
||||
status = "connecting…";
|
||||
isLive = false;
|
||||
|
||||
Steps =
|
||||
[
|
||||
new ConnectionStepViewModel(ConnectionStep.PreparingTerminal, "Starting the terminal"),
|
||||
new ConnectionStepViewModel(ConnectionStep.Reaching, "Reaching the host"),
|
||||
new ConnectionStepViewModel(ConnectionStep.CheckingHostKey, "Checking the host key"),
|
||||
new ConnectionStepViewModel(ConnectionStep.Authenticating, "Signing in"),
|
||||
new ConnectionStepViewModel(ConnectionStep.OpeningShell, "Opening the shell"),
|
||||
];
|
||||
|
||||
// The first step is running before anything is awaited, because it is: the tab is created in the
|
||||
// same turn as the click and the renderer wait starts immediately after. A list that opened with
|
||||
// every row pending would show a connection that had not begun, which is one turn of the dispatcher
|
||||
// away from being untrue and is the turn the card is first drawn in.
|
||||
status = Steps[0].Caption;
|
||||
Steps[0].State = ConnectionStepState.Running;
|
||||
}
|
||||
|
||||
/// <summary>A tab for a session that is already open.</summary>
|
||||
@@ -72,6 +209,12 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
state = TerminalTabState.Open;
|
||||
status = string.Empty;
|
||||
isLive = true;
|
||||
|
||||
// A session that already exists got through every step by definition, even though this tab watched
|
||||
// none of them happen — an adopted session is one whose connecting tab the user closed. The list is
|
||||
// never drawn for a tab in this state; it is filled in so that nothing downstream has to treat "open"
|
||||
// as a fourth answer to "how far did it get".
|
||||
CompleteSteps();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -128,6 +271,36 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
/// </remarks>
|
||||
internal string? IdentityLabel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How far this connection got, step by step, for the card that stands in for the pane.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Fixed at construction and never added to or removed from — the steps of a connection are known before
|
||||
/// it starts, and only their state changes — so a plain array is enough and the view needs no collection
|
||||
/// change notification for it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every row here is reported, not guessed.</b> The states come from
|
||||
/// <see cref="SshConnectionPhase"/>, raised by the handshake itself at the moment each part of it begins.
|
||||
/// Nothing on this list is a timer, a fraction, or a step this view model decided had probably finished
|
||||
/// by now. That is the whole reason it is worth showing: a card that invented plausible progress would be
|
||||
/// indistinguishable from one that had stopped receiving any.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal IReadOnlyList<ConnectionStepViewModel> Steps { get; }
|
||||
|
||||
/// <summary>How many steps are behind the attempt, for the card's track.</summary>
|
||||
/// <remarks>
|
||||
/// Counted rather than stored, and it counts <see cref="ConnectionStepState.Done"/> alone: the running
|
||||
/// step is deliberately not half a step. The track fills to where the attempt has actually got to and
|
||||
/// stops there, which is the same promise the list itself makes.
|
||||
/// </remarks>
|
||||
internal int StepsDone => Steps.Count(step => step.IsDone);
|
||||
|
||||
/// <summary>How many steps there are, for the card's track.</summary>
|
||||
internal int StepCount => Steps.Count;
|
||||
|
||||
/// <inheritdoc cref="TerminalTabState" />
|
||||
[ObservableProperty]
|
||||
private TerminalTabState state;
|
||||
@@ -178,6 +351,95 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private bool isShowing;
|
||||
|
||||
/// <summary>
|
||||
/// How the shell behind this tab ended, or null while it has not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null covers three states that need no telling apart here — still connecting, still running, and a
|
||||
/// connection that never happened — because the only thing anything asks this is which ending to word,
|
||||
/// and none of the three has one.
|
||||
/// </remarks>
|
||||
internal SshSessionEnd? EndReason { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The sentence the shell's end came with, or null while there is none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same string the renderer wrote into the pane, so the detail under a status line and the line in
|
||||
/// the scrollback are one sentence rather than two accounts that can drift.
|
||||
/// </remarks>
|
||||
internal string? EndDescription { get; private set; }
|
||||
|
||||
/// <summary>Whether this tab has a session that has stopped, and something to say about it.</summary>
|
||||
internal bool HasEnded => State is TerminalTabState.Open && !IsLive;
|
||||
|
||||
/// <summary>
|
||||
/// The sentence the strip under a stopped terminal prints: why a reconnect was refused if one just was,
|
||||
/// and otherwise how the session ended.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The refusal wins because it is the newer answer and it is about something the user just did. A tab
|
||||
/// whose reconnect was turned down — the machine has left the keychain, the key it was bound to is gone —
|
||||
/// would otherwise go on printing how the shell ended, which is true and is not what was just asked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Status"/> is where the refusal lands because that is already what this property means:
|
||||
/// what the tab has to say for itself. It is empty for the whole of a session's life —
|
||||
/// <see cref="Opened"/> clears it — so a non-empty one on a stopped tab can only be something that
|
||||
/// happened after the shell did.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string? EndedDetail => Status is { Length: > 0 } spoken ? spoken : EndDescription;
|
||||
|
||||
/// <summary>Whether this tab's session ended because the connection broke rather than because the shell did.</summary>
|
||||
/// <remarks>
|
||||
/// The tab strip's dot binds this, so the two endings are told apart at a glance in the one place every
|
||||
/// terminal is listed. They used to draw the same grey, which made a tab that had been cut off
|
||||
/// indistinguishable from one somebody had closed down properly.
|
||||
/// </remarks>
|
||||
internal bool IsDropped => EndReason is SshSessionEnd.ConnectionLost;
|
||||
|
||||
/// <summary>
|
||||
/// What the status bar says instead of CONNECTED, or null while nothing has ended.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two words rather than one, because the two endings are read completely differently. A shell that
|
||||
/// exited is the ordinary end of a day's work and wants no alarm; a connection that broke is a machine
|
||||
/// that may well still be there, and is the whole reason the reconnect beside it exists.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="SshSessionEnd.ClosedLocally"/> has no word here on purpose. This client closed the session,
|
||||
/// which means a tab is being removed or reconnected — there is no moment at which somebody reads a
|
||||
/// status line about it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string? EndedSummary => EndReason switch
|
||||
{
|
||||
SshSessionEnd.ConnectionLost => "DISCONNECTED",
|
||||
SshSessionEnd.RemoteClosed => "SHELL EXITED",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether this tab is in a state where dialling again would mean something.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// True for a shell that ended however it ended, and for a connection that was refused. All three are a
|
||||
/// tab with no live session and a machine it was pointed at — and offering the retry on a refusal is
|
||||
/// worth as much as offering it on a drop, because the commonest refusal is a host that was not up yet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It says the tab is willing, not that the shell can do it.</b> Whether there is anything to dial —
|
||||
/// a keychain entry under this tab's name, and one that does not need a password typed first — is the
|
||||
/// shell's question, because this view model has never heard of a vault. See
|
||||
/// <c>MainWindowViewModel.CanReconnectSelectedTab</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool CanOfferReconnect => HasEnded || IsFailed;
|
||||
|
||||
/// <summary>Whether there is a pane behind this tab.</summary>
|
||||
internal bool HasSession => State is TerminalTabState.Open;
|
||||
|
||||
@@ -187,13 +449,70 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
/// <summary>Whether this tab is a connection that never happened.</summary>
|
||||
internal bool IsFailed => State is TerminalTabState.Failed;
|
||||
|
||||
/// <summary>
|
||||
/// Records that the connection has reached a named step.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Everything before <paramref name="step"/> is marked done, because a phase that has begun is proof the
|
||||
/// ones before it ended — the handshake is a sequence and there is no way to be at one point in it
|
||||
/// without having passed the earlier ones. That is also what covers a step too fast to observe: it is
|
||||
/// closed by its successor rather than needing a report of its own.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Monotonic, and silently so. A report that has already been passed is ignored rather than rewinding
|
||||
/// the list, because the one thing that can produce one is a retry after the host-key question, and a
|
||||
/// card that jumped backwards would read as the connection having come undone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void Advance(ConnectionStep step)
|
||||
{
|
||||
if (State is not TerminalTabState.Connecting)
|
||||
{
|
||||
// Nothing to draw and nothing to correct. A late report from a handshake that has since
|
||||
// finished or been given up on is not worth reopening a settled tab for.
|
||||
return;
|
||||
}
|
||||
|
||||
var reached = Steps.FirstOrDefault(row => row.Step == step);
|
||||
|
||||
if (reached is null || reached.IsDone)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var row in Steps)
|
||||
{
|
||||
if (row.Step < step)
|
||||
{
|
||||
row.State = ConnectionStepState.Done;
|
||||
}
|
||||
else if (row.Step == step)
|
||||
{
|
||||
row.State = ConnectionStepState.Running;
|
||||
}
|
||||
}
|
||||
|
||||
Status = reached.Caption;
|
||||
OnPropertyChanged(nameof(StepsDone));
|
||||
}
|
||||
|
||||
/// <summary>Takes ownership of the session that has just opened for this tab.</summary>
|
||||
internal void Opened(uint sessionId)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
Status = string.Empty;
|
||||
|
||||
// Cleared before the flags below raise off them. A tab reconnecting carries one of these from the
|
||||
// session that just ended, and a live terminal that still says how the last one stopped is the
|
||||
// stale-fact failure this pair exists to remove.
|
||||
EndReason = null;
|
||||
EndDescription = null;
|
||||
|
||||
IsLive = true;
|
||||
State = TerminalTabState.Open;
|
||||
|
||||
CompleteSteps();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -208,13 +527,152 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
{
|
||||
Status = reason;
|
||||
IsLive = false;
|
||||
|
||||
// A refusal is not an ending of the kind Ended records — there was never a session to end — so
|
||||
// anything a previous attempt on this tab left behind goes with it. Without this, a reconnect that
|
||||
// is itself refused would draw the refusal and the old shell's ending at once.
|
||||
EndReason = null;
|
||||
EndDescription = null;
|
||||
|
||||
// Before the state change, so the list is already correct the first time a view asks. The step that
|
||||
// was running is where it stopped, and the ones behind it stay done: how far a refused connection
|
||||
// got is the most useful thing the card still knows, and it is the difference between "that host is
|
||||
// not there" and "that host is there and would not have me".
|
||||
foreach (var row in Steps)
|
||||
{
|
||||
if (row.IsRunning)
|
||||
{
|
||||
row.State = ConnectionStepState.Stopped;
|
||||
}
|
||||
}
|
||||
|
||||
State = TerminalTabState.Failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that the shell behind this tab has stopped, and how.
|
||||
/// </summary>
|
||||
/// <param name="end">Which of the three ways it ended.</param>
|
||||
/// <param name="description">The sentence the pump wrote into the pane, for the detail line.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The tab stays and its pane stays, exactly as before: the last thing the remote said is usually why the
|
||||
/// shell ended, and the renderer has already written this same sentence into the scrollback. What is new
|
||||
/// is that the tab now knows <em>which</em> ending it was, which is the difference between a shell that is
|
||||
/// finished and a machine that is probably still there — see <see cref="CanOfferReconnect"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ignored for a tab that is connecting or failed. A session end can only be about a session, and a late
|
||||
/// announcement for one this tab has already been reconnected past would otherwise reach back and mark a
|
||||
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void Ended(SshSessionEnd end, string description)
|
||||
{
|
||||
if (State is not TerminalTabState.Open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EndReason = end;
|
||||
EndDescription = description;
|
||||
IsLive = false;
|
||||
|
||||
OnPropertyChanged(nameof(EndedDetail));
|
||||
OnPropertyChanged(nameof(EndedSummary));
|
||||
OnPropertyChanged(nameof(EndDescription));
|
||||
OnPropertyChanged(nameof(IsDropped));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts this tab back to connecting, for an attempt that is about to be made on the same machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The tab is reused rather than a second one opened beside it, because reconnecting is what a person
|
||||
/// means when they say it: the tab they were working in comes back, in the place it was, still selected.
|
||||
/// A new tab would leave the strip holding two entries for one machine, one of them a headstone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The session id is dropped here, and the pane it named is the caller's to reclaim.</b> Nothing in
|
||||
/// this view model can send a frame; what it can do is stop claiming a session that is about to stop
|
||||
/// being this tab's. See <c>MainWindowViewModel.ReconnectTabAsync</c>, which closes the old session for
|
||||
/// its pane's sake — a pane holds an xterm, its scrollback and a WebGL context, and browsers hand out
|
||||
/// about sixteen of the last.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The step list is rebuilt to how it looked at the start of the first attempt rather than left as it
|
||||
/// was. A list still showing where the last attempt got to would read as progress this one had already
|
||||
/// made, which is the one thing the card promises never to do.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void Reconnecting()
|
||||
{
|
||||
SessionId = 0;
|
||||
EndReason = null;
|
||||
EndDescription = null;
|
||||
IsLive = false;
|
||||
|
||||
// Cleared with the session they were facts about. Keeping them would put the previous connection's
|
||||
// cipher in the status bar beside a handshake that has not negotiated one yet.
|
||||
StartedAt = null;
|
||||
Cipher = null;
|
||||
HostKeyAlgorithm = null;
|
||||
IdentityLabel = null;
|
||||
|
||||
foreach (var row in Steps)
|
||||
{
|
||||
row.State = ConnectionStepState.Pending;
|
||||
}
|
||||
|
||||
// The same opening position the constructor sets, and for the same reason: the renderer wait starts
|
||||
// in this turn, so a list of five pending rows would show an attempt that had not begun.
|
||||
Steps[0].State = ConnectionStepState.Running;
|
||||
Status = Steps[0].Caption;
|
||||
|
||||
State = TerminalTabState.Connecting;
|
||||
|
||||
OnPropertyChanged(nameof(StepsDone));
|
||||
OnPropertyChanged(nameof(EndedDetail));
|
||||
OnPropertyChanged(nameof(EndedSummary));
|
||||
OnPropertyChanged(nameof(EndDescription));
|
||||
OnPropertyChanged(nameof(IsDropped));
|
||||
}
|
||||
|
||||
/// <summary>Marks every step done, for a connection that is no longer being waited on.</summary>
|
||||
private void CompleteSteps()
|
||||
{
|
||||
foreach (var row in Steps)
|
||||
{
|
||||
row.State = ConnectionStepState.Done;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(StepsDone));
|
||||
}
|
||||
|
||||
partial void OnStateChanged(TerminalTabState value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasSession));
|
||||
OnPropertyChanged(nameof(IsConnecting));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
OnPropertyChanged(nameof(HasEnded));
|
||||
OnPropertyChanged(nameof(CanOfferReconnect));
|
||||
|
||||
// Opened and Failed both clear the previous ending and then land here, so this is where a dot that
|
||||
// was red for the last session goes back to whatever the new state deserves.
|
||||
OnPropertyChanged(nameof(IsDropped));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <see cref="HasEnded"/> and <see cref="CanOfferReconnect"/> are both partly this flag, and the strip
|
||||
/// binds them — so a shell that stops has to move them in the same notification that greys its dot.
|
||||
/// </remarks>
|
||||
/// <remarks>The strip under a stopped terminal reads this as well as the ending; see <see cref="EndedDetail"/>.</remarks>
|
||||
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(EndedDetail));
|
||||
|
||||
partial void OnIsLiveChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasEnded));
|
||||
OnPropertyChanged(nameof(CanOfferReconnect));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -74,15 +74,21 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
|
||||
|
||||
/// <summary>How long to wait before the first pass.</summary>
|
||||
/// <summary>How long the look taken on the way to a restart may take before it is given up on.</summary>
|
||||
/// <remarks>
|
||||
/// A delay, where <c>VaultViewModel</c>'s sync loop runs a pass immediately. The difference is what the
|
||||
/// user is waiting for: a vault edited on another machine should be current by the time they have
|
||||
/// finished reading the list, whereas nothing anybody does in their first two minutes depends on an
|
||||
/// update. Launch is already contending for the network and the CPU with a schema migration, a resumed
|
||||
/// sign-in and a first sync, at the one moment somebody is watching the window.
|
||||
/// <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 FirstCheckDelay = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan LastLookTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly IUpdateChannel updates;
|
||||
private readonly ClientSettingsStore settings;
|
||||
@@ -153,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;
|
||||
@@ -165,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>
|
||||
@@ -211,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));
|
||||
@@ -242,16 +299,40 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
loop = RunCheckLoopAsync(lifetime.Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The first pass runs at launch, with no delay in front of it.</b> It used to wait two minutes, on
|
||||
/// the argument that nothing anybody does in their first two minutes depends on an update and launch is
|
||||
/// already contending for the network with a schema migration, a resumed sign-in and a first sync. What
|
||||
/// that argument leaves out is the run that is over before the two minutes are: a client opened to reach
|
||||
/// one host and closed again never checks at all, and a machine used that way is exactly the one ADR
|
||||
/// 0011 warns about — quietly a year behind, with the mechanism to fix it switched on and never reached.
|
||||
/// Every start now asks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The yield is what keeps that off the launch path.</b> <see cref="Start"/> is called from
|
||||
/// <c>MainWindowViewModel.StartAsync</c> before the migration, so running the pass inline would put
|
||||
/// whatever the channel does before its own first await — Velopack reads the install layout from disk —
|
||||
/// between the user and their window. Yielding hands the rest of the launch back and lets the check run
|
||||
/// in a later turn, which is the same moment in every sense that matters and none of the cost.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RunCheckLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true);
|
||||
await Task.Yield();
|
||||
|
||||
using var timer = new PeriodicTimer(CheckInterval, clock);
|
||||
|
||||
do
|
||||
{
|
||||
// Task.Yield takes no token, unlike the delay it replaced, so a shutdown that lands while
|
||||
// the loop is waiting to be handed back the thread has to be observed here rather than
|
||||
// only at the next tick. Otherwise an application closed during launch spends its last
|
||||
// moment asking a release channel about a build it is not going to run.
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
await CheckOnceAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true));
|
||||
@@ -264,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;
|
||||
}
|
||||
@@ -325,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
|
||||
@@ -368,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
|
||||
@@ -392,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user